sapiopycommons 2024.8.15a304__py3-none-any.whl → 2024.8.19a305__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 (28) hide show
  1. sapiopycommons/callbacks/callback_util.py +122 -25
  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 +296 -0
  6. sapiopycommons/datatype/attachment_util.py +15 -6
  7. sapiopycommons/eln/experiment_handler.py +193 -39
  8. sapiopycommons/files/complex_data_loader.py +1 -1
  9. sapiopycommons/files/file_bridge.py +1 -1
  10. sapiopycommons/files/file_bridge_handler.py +21 -0
  11. sapiopycommons/files/file_util.py +38 -5
  12. sapiopycommons/files/file_validator.py +21 -6
  13. sapiopycommons/files/file_writer.py +44 -15
  14. sapiopycommons/general/aliases.py +93 -2
  15. sapiopycommons/general/audit_log.py +200 -0
  16. sapiopycommons/general/popup_util.py +17 -0
  17. sapiopycommons/general/sapio_links.py +48 -0
  18. sapiopycommons/general/time_util.py +40 -0
  19. sapiopycommons/recordmodel/record_handler.py +114 -17
  20. sapiopycommons/rules/eln_rule_handler.py +29 -22
  21. sapiopycommons/rules/on_save_rule_handler.py +29 -28
  22. sapiopycommons/webhook/webhook_handlers.py +90 -26
  23. sapiopycommons/webhook/webservice_handlers.py +67 -0
  24. {sapiopycommons-2024.8.15a304.dist-info → sapiopycommons-2024.8.19a305.dist-info}/METADATA +1 -1
  25. sapiopycommons-2024.8.19a305.dist-info/RECORD +50 -0
  26. sapiopycommons-2024.8.15a304.dist-info/RECORD +0 -43
  27. {sapiopycommons-2024.8.15a304.dist-info → sapiopycommons-2024.8.19a305.dist-info}/WHEEL +0 -0
  28. {sapiopycommons-2024.8.15a304.dist-info → sapiopycommons-2024.8.19a305.dist-info}/licenses/LICENSE +0 -0
@@ -1,5 +1,8 @@
1
+ from __future__ import annotations
2
+
1
3
  from collections.abc import Iterable
2
4
  from typing import Any
5
+ from weakref import WeakValueDictionary
3
6
 
4
7
  from sapiopylib.rest.DataRecordManagerService import DataRecordManager
5
8
  from sapiopylib.rest.User import SapioUser
@@ -7,6 +10,7 @@ from sapiopylib.rest.pojo.CustomReport import CustomReportCriteria, RawReportTer
7
10
  from sapiopylib.rest.pojo.DataRecord import DataRecord
8
11
  from sapiopylib.rest.pojo.DataRecordPaging import DataRecordPojoPageCriteria
9
12
  from sapiopylib.rest.pojo.datatype.FieldDefinition import FieldType
13
+ from sapiopylib.rest.pojo.eln.SapioELNEnums import ElnBaseDataType
10
14
  from sapiopylib.rest.pojo.webhook.WebhookContext import SapioWebhookContext
11
15
  from sapiopylib.rest.utils.autopaging import QueryDataRecordsAutoPager, QueryDataRecordByIdListAutoPager, \
12
16
  QueryAllRecordsOfTypeAutoPager
@@ -16,6 +20,7 @@ from sapiopylib.rest.utils.recordmodel.RecordModelManager import RecordModelMana
16
20
  from sapiopylib.rest.utils.recordmodel.RecordModelWrapper import WrappedType, WrappedRecordModel
17
21
  from sapiopylib.rest.utils.recordmodel.RelationshipPath import RelationshipPath, RelationshipNode, \
18
22
  RelationshipNodeType
23
+ from sapiopylib.rest.utils.recordmodel.ancestry import RecordModelAncestorManager
19
24
 
20
25
  from sapiopycommons.general.aliases import RecordModel, SapioRecord, FieldMap
21
26
  from sapiopycommons.general.custom_report_util import CustomReportUtil
@@ -32,16 +37,37 @@ class RecordHandler:
32
37
  rec_man: RecordModelManager
33
38
  inst_man: RecordModelInstanceManager
34
39
  rel_man: RecordModelRelationshipManager
40
+ an_man: RecordModelAncestorManager
41
+
42
+ __instances: WeakValueDictionary[SapioUser, RecordHandler] = WeakValueDictionary()
43
+ __initialized: bool
44
+
45
+ def __new__(cls, context: SapioWebhookContext | SapioUser):
46
+ """
47
+ :param context: The current webhook context or a user object to send requests from.
48
+ """
49
+ user = context if isinstance(context, SapioUser) else context.user
50
+ obj = cls.__instances.get(user)
51
+ if not obj:
52
+ obj = object.__new__(cls)
53
+ obj.__initialized = False
54
+ cls.__instances[user] = obj
55
+ return obj
35
56
 
36
57
  def __init__(self, context: SapioWebhookContext | SapioUser):
37
58
  """
38
59
  :param context: The current webhook context or a user object to send requests from.
39
60
  """
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,6 +88,7 @@ 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
94
  def query_models(self, wrapper_type: type[WrappedType], field: str, value_list: Iterable[Any],
@@ -168,6 +196,19 @@ class RecordHandler:
168
196
  pager.max_page = page_limit
169
197
  return self.wrap_models(pager.get_all_at_once(), wrapper_type), pager.next_page_criteria
170
198
 
199
+ def query_models_by_id_and_map(self, wrapper_type: type[WrappedType], ids: Iterable[int],
200
+ page_limit: int | None = None) -> dict[int, WrappedType]:
201
+ """
202
+ Shorthand for using the data record manager to query for a list of data records by record ID
203
+ and then converting the results into a dictionary of record ID to the record model for that ID.
204
+
205
+ :param wrapper_type: The record model wrapper to use.
206
+ :param ids: The list of record IDs to query.
207
+ :param page_limit: The maximum number of pages to query. If None, exhausts all possible pages.
208
+ :return: The record models for the queried records mapped in a dictionary by their record ID.
209
+ """
210
+ return {x.record_id: x for x in self.query_models_by_id(wrapper_type, ids, page_limit)}
211
+
171
212
  def query_all_models(self, wrapper_type: type[WrappedType], page_limit: int | None = None) -> list[WrappedType]:
172
213
  """
173
214
  Shorthand for using the data record manager to query for all data records of a given type
@@ -497,7 +538,7 @@ class RecordHandler:
497
538
 
498
539
  @staticmethod
499
540
  def map_by_child(models: Iterable[RecordModel], child_type: type[WrappedType]) \
500
- -> dict[WrappedType, list[RecordModel]]:
541
+ -> dict[WrappedType, RecordModel]:
501
542
  """
502
543
  Take a list of record models and map them by their children. Essentially an inversion of map_to_child.
503
544
  If two records share the same child, an exception will be thrown. The children must already be loaded.
@@ -831,8 +872,6 @@ class RecordHandler:
831
872
  path, if any. The hierarchy must be linear (1:1 relationship between data types at every step) and the
832
873
  relationship path must already be loaded.
833
874
 
834
- Currently, the relationship path may only contain parent/child nodes.
835
-
836
875
  :param models: A list of record models.
837
876
  :param path: The relationship path to follow.
838
877
  :param wrapper_type: The record model wrapper to use.
@@ -843,15 +882,44 @@ class RecordHandler:
843
882
  # PR-46832: Update path traversal to account for changes to RelationshipPath in Sapiopylib.
844
883
  path: list[RelationshipNode] = path.path
845
884
  for model in models:
846
- current: PyRecordModel = model if isinstance(model, PyRecordModel) else model.backing_model
885
+ current: PyRecordModel | None = model if isinstance(model, PyRecordModel) else model.backing_model
847
886
  for node in path:
848
- direction = node.direction
887
+ data_type: str = node.data_type_name
888
+ direction: RelationshipNodeType = node.direction
849
889
  if current is None:
850
890
  break
851
891
  if direction == RelationshipNodeType.CHILD:
852
- current = current.get_child_of_type(node.data_type_name)
892
+ current = current.get_child_of_type(data_type)
853
893
  elif direction == RelationshipNodeType.PARENT:
854
- current = current.get_parent_of_type(node.data_type_name)
894
+ current = current.get_parent_of_type(data_type)
895
+ elif direction == RelationshipNodeType.ANCESTOR:
896
+ ancestors: list[PyRecordModel] = list(self.an_man.get_ancestors_of_type(current, data_type))
897
+ if not ancestors:
898
+ current = None
899
+ elif len(ancestors) > 1:
900
+ raise SapioException(f"Hierarchy contains multiple ancestors of type {data_type}.")
901
+ else:
902
+ current = ancestors[0]
903
+ elif direction == RelationshipNodeType.DESCENDANT:
904
+ descendants: list[PyRecordModel] = list(self.an_man.get_descendant_of_type(current, data_type))
905
+ if not descendants:
906
+ current = None
907
+ elif len(descendants) > 1:
908
+ raise SapioException(f"Hierarchy contains multiple descendants of type {data_type}.")
909
+ else:
910
+ current = descendants[0]
911
+ elif direction == RelationshipNodeType.FORWARD_SIDE_LINK:
912
+ current = current.get_forward_side_link(node.data_field_name)
913
+ elif direction == RelationshipNodeType.REVERSE_SIDE_LINK:
914
+ field_name: str = node.data_field_name
915
+ reverse_links: list[PyRecordModel] = current.get_reverse_side_link(field_name, data_type)
916
+ if not reverse_links:
917
+ current = None
918
+ elif len(reverse_links) > 1:
919
+ raise SapioException(f"Hierarchy contains multiple reverse links of type {data_type} on field "
920
+ f"{field_name}.")
921
+ else:
922
+ current = reverse_links[0]
855
923
  else:
856
924
  raise SapioException("Unsupported path direction.")
857
925
  ret_dict.update({model: self.inst_man.wrap(current, wrapper_type) if current else None})
@@ -864,8 +932,6 @@ class RecordHandler:
864
932
  path, if any. The hierarchy may be non-linear (1:Many relationships between data types are allowed) and the
865
933
  relationship path must already be loaded.
866
934
 
867
- Currently, the relationship path may only contain parent/child nodes.
868
-
869
935
  :param models: A list of record models.
870
936
  :param path: The relationship path to follow.
871
937
  :param wrapper_type: The record model wrapper to use.
@@ -880,14 +946,23 @@ class RecordHandler:
880
946
  next_search: set[PyRecordModel] = set()
881
947
  # Exhaust the records at each step in the path, then use those records for the next step.
882
948
  for node in path:
883
- direction = node.direction
949
+ data_type: str = node.data_type_name
950
+ direction: RelationshipNodeType = node.direction
884
951
  if len(current_search) == 0:
885
952
  break
886
953
  for search in current_search:
887
954
  if direction == RelationshipNodeType.CHILD:
888
- next_search.update(search.get_children_of_type(node.data_type_name))
955
+ next_search.update(search.get_children_of_type(data_type))
889
956
  elif direction == RelationshipNodeType.PARENT:
890
- next_search.update(search.get_parents_of_type(node.data_type_name))
957
+ next_search.update(search.get_parents_of_type(data_type))
958
+ elif direction == RelationshipNodeType.ANCESTOR:
959
+ next_search.update(self.an_man.get_ancestors_of_type(search, data_type))
960
+ elif direction == RelationshipNodeType.DESCENDANT:
961
+ next_search.update(self.an_man.get_descendant_of_type(search, data_type))
962
+ elif direction == RelationshipNodeType.FORWARD_SIDE_LINK:
963
+ next_search.add(search.get_forward_side_link(node.data_field_name))
964
+ elif direction == RelationshipNodeType.REVERSE_SIDE_LINK:
965
+ next_search.update(search.get_reverse_side_link(node.data_field_name, data_type))
891
966
  else:
892
967
  raise SapioException("Unsupported path direction.")
893
968
  current_search = next_search
@@ -908,8 +983,6 @@ class RecordHandler:
908
983
  relationships (e.g. a sample which is aliquoted to a number of samples, then those aliquots are pooled back
909
984
  together into a single sample).
910
985
 
911
- Currently, the relationship path may only contain parent/child nodes.
912
-
913
986
  :param models: A list of record models.
914
987
  :param path: The relationship path to follow.
915
988
  :param wrapper_type: The record model wrapper to use.
@@ -922,13 +995,22 @@ class RecordHandler:
922
995
  for model in models:
923
996
  current: list[PyRecordModel] = [model if isinstance(model, PyRecordModel) else model.backing_model]
924
997
  for node in path:
925
- direction = node.direction
998
+ data_type: str = node.data_type_name
999
+ direction: RelationshipNodeType = node.direction
926
1000
  if len(current) == 0:
927
1001
  break
928
1002
  if direction == RelationshipNodeType.CHILD:
929
- current = current[0].get_children_of_type(node.data_type_name)
1003
+ current = current[0].get_children_of_type(data_type)
930
1004
  elif direction == RelationshipNodeType.PARENT:
931
- current = current[0].get_parents_of_type(node.data_type_name)
1005
+ current = current[0].get_parents_of_type(data_type)
1006
+ elif direction == RelationshipNodeType.ANCESTOR:
1007
+ current = list(self.an_man.get_ancestors_of_type(current[0], data_type))
1008
+ elif direction == RelationshipNodeType.DESCENDANT:
1009
+ current = list(self.an_man.get_descendant_of_type(current[0], data_type))
1010
+ elif direction == RelationshipNodeType.FORWARD_SIDE_LINK:
1011
+ current = [current[0].get_forward_side_link(node.data_field_name)]
1012
+ elif direction == RelationshipNodeType.REVERSE_SIDE_LINK:
1013
+ current = current[0].get_reverse_side_link(node.data_field_name, data_type)
932
1014
  else:
933
1015
  raise SapioException("Unsupported path direction.")
934
1016
  ret_dict.update({model: self.inst_man.wrap(current[0], wrapper_type) if current else None})
@@ -959,3 +1041,18 @@ class RecordHandler:
959
1041
  f"encountered in system that matches all provided identifiers.")
960
1042
  unique_record = result
961
1043
  return unique_record
1044
+
1045
+ @staticmethod
1046
+ def __verify_data_type(records: Iterable[DataRecord], wrapper_type: type[WrappedType]) -> None:
1047
+ """
1048
+ Throw an exception if the data type of the given records and wrapper don't match.
1049
+ """
1050
+ model_type: str = wrapper_type.get_wrapper_data_type_name()
1051
+ for record in records:
1052
+ record_type: str = record.data_type_name
1053
+ # Account for ELN data type records.
1054
+ if ElnBaseDataType.is_eln_type(record_type):
1055
+ record_type = ElnBaseDataType.get_base_type(record_type).data_type_name
1056
+ if record_type != model_type:
1057
+ raise SapioException(f"Data record of type {record_type} cannot be wrapped by the record model wrapper "
1058
+ f"of type {model_type}")
@@ -1,5 +1,10 @@
1
+ from __future__ import annotations
2
+
3
+ from weakref import WeakValueDictionary
4
+
5
+ from sapiopylib.rest.User import SapioUser
1
6
  from sapiopylib.rest.pojo.DataRecord import DataRecord
2
- from sapiopylib.rest.pojo.webhook.VeloxRules import VeloxRuleType, VeloxRuleParser
7
+ from sapiopylib.rest.pojo.eln.SapioELNEnums import ElnBaseDataType
3
8
  from sapiopylib.rest.pojo.webhook.WebhookContext import SapioWebhookContext
4
9
  from sapiopylib.rest.utils.recordmodel.RecordModelManager import RecordModelManager, RecordModelInstanceManager
5
10
  from sapiopylib.rest.utils.recordmodel.RecordModelWrapper import WrappedType
@@ -12,7 +17,6 @@ from sapiopycommons.general.exceptions import SapioException
12
17
  class ElnRuleHandler:
13
18
  """
14
19
  A class which helps with the parsing and navigation of the ELN rule result map of a webhook context.
15
- TODO: Add functionality around the VeloxRuleType of the rule results.
16
20
  """
17
21
  __context: SapioWebhookContext
18
22
  """The context that this handler is working from."""
@@ -34,7 +38,25 @@ class ElnRuleHandler:
34
38
  """A mapping of entry name to the lists of field maps for that entry, each grouping of field maps being mapped by
35
39
  its data type."""
36
40
 
41
+ __instances: WeakValueDictionary[SapioUser, ElnRuleHandler] = WeakValueDictionary()
42
+ __initialized: bool
43
+
44
+ def __new__(cls, context: SapioWebhookContext):
45
+ if context.velox_eln_rule_result_map is None:
46
+ raise SapioException("No Velox ELN rule result map in context for ElnRuleHandler to parse.")
47
+ user = context if isinstance(context, SapioUser) else context.user
48
+ obj = cls.__instances.get(user)
49
+ if not obj:
50
+ obj = object.__new__(cls)
51
+ obj.__initialized = False
52
+ cls.__instances[user] = obj
53
+ return obj
54
+
37
55
  def __init__(self, context: SapioWebhookContext):
56
+ if self.__initialized:
57
+ return
58
+ self.__initialized = True
59
+
38
60
  if context.velox_eln_rule_result_map is None:
39
61
  raise SapioException("No Velox ELN rule result map in context for ElnRuleHandler to parse.")
40
62
  self.__context = context
@@ -64,13 +86,8 @@ class ElnRuleHandler:
64
86
  # Get the data type of this record. If this is an ELN type, ignore the digits.
65
87
  data_type: str = record.data_type_name
66
88
  # PR-46331: Ensure that all ELN types are converted to their base data type name.
67
- # TODO: Use ElnBaseDataType.is_eln_type when it is no longer bugged in sapiopylib.
68
- if data_type.startswith("ELNExperiment_"):
69
- data_type = "ELNExperiment"
70
- elif data_type.startswith("ELNExperimentDetail_"):
71
- data_type = "ELNExperimentDetail"
72
- elif data_type.startswith("ELNSampleDetail_"):
73
- data_type = "ELNSampleDetail"
89
+ if ElnBaseDataType.is_eln_type(data_type):
90
+ data_type = ElnBaseDataType.get_base_type(data_type).data_type_name
74
91
  # Update the list of records of this type that exist so far globally.
75
92
  self.__records.setdefault(data_type, set()).add(record)
76
93
  # Do the same for the list of records of this type for this specific entry.
@@ -85,19 +102,9 @@ class ElnRuleHandler:
85
102
  entry_dict: dict[str, dict[int, FieldMap]] = {}
86
103
  for record_result in entry_results:
87
104
  for result in record_result.velox_type_rule_field_map_result_list:
88
- # TODO: sapiopylib currently has a bug where this velox_type_pojo variable is stored as a dict instead
89
- # of as the intended VeloxRuleType object. Parse that dict as a VeloxRuleType before use.
90
- velox_type: VeloxRuleType | dict = result.velox_type_pojo
91
- if isinstance(velox_type, dict):
92
- velox_type: VeloxRuleType = VeloxRuleParser.parse_velox_rule_type(velox_type)
93
- data_type: str = velox_type.data_type_name
94
- # TODO: Use ElnBaseDataType.is_eln_type when it is no longer bugged in sapiopylib.
95
- if data_type.startswith("ELNExperiment_"):
96
- data_type = "ELNExperiment"
97
- elif data_type.startswith("ELNExperimentDetail_"):
98
- data_type = "ELNExperimentDetail"
99
- elif data_type.startswith("ELNSampleDetail_"):
100
- data_type = "ELNSampleDetail"
105
+ data_type: str = result.velox_type_pojo.data_type_name
106
+ if ElnBaseDataType.is_eln_type(data_type):
107
+ data_type = ElnBaseDataType.get_base_type(data_type).data_type_name
101
108
  for field_map in result.field_map_list:
102
109
  rec_id: int = field_map.get("RecordId")
103
110
  self.__field_maps.setdefault(data_type, {}).update({rec_id: field_map})
@@ -1,5 +1,10 @@
1
+ from __future__ import annotations
2
+
3
+ from weakref import WeakValueDictionary
4
+
5
+ from sapiopylib.rest.User import SapioUser
1
6
  from sapiopylib.rest.pojo.DataRecord import DataRecord
2
- from sapiopylib.rest.pojo.webhook.VeloxRules import VeloxRuleType, VeloxRuleParser
7
+ from sapiopylib.rest.pojo.eln.SapioELNEnums import ElnBaseDataType
3
8
  from sapiopylib.rest.pojo.webhook.WebhookContext import SapioWebhookContext
4
9
  from sapiopylib.rest.utils.recordmodel.RecordModelManager import RecordModelManager, RecordModelInstanceManager
5
10
  from sapiopylib.rest.utils.recordmodel.RecordModelWrapper import WrappedType
@@ -12,7 +17,6 @@ from sapiopycommons.general.exceptions import SapioException
12
17
  class OnSaveRuleHandler:
13
18
  """
14
19
  A class which helps with the parsing and navigation of the on save rule result map of a webhook context.
15
- TODO: Add functionality around the VeloxRuleType of the rule results.
16
20
  """
17
21
  __context: SapioWebhookContext
18
22
  """The context that this handler is working from."""
@@ -34,7 +38,25 @@ class OnSaveRuleHandler:
34
38
  """A mapping of record IDs of records in the context.data_record_list to the field maps related to that
35
39
  record, each grouping of field maps being mapped by its data type."""
36
40
 
41
+ __instances: WeakValueDictionary[SapioUser, OnSaveRuleHandler] = WeakValueDictionary()
42
+ __initialized: bool
43
+
44
+ def __new__(cls, context: SapioWebhookContext):
45
+ if context.velox_on_save_result_map is None:
46
+ raise SapioException("No Velox on save rule result map in context for OnSaveRuleHandler to parse.")
47
+ user = context if isinstance(context, SapioUser) else context.user
48
+ obj = cls.__instances.get(user)
49
+ if not obj:
50
+ obj = object.__new__(cls)
51
+ obj.__initialized = False
52
+ cls.__instances[user] = obj
53
+ return obj
54
+
37
55
  def __init__(self, context: SapioWebhookContext):
56
+ if self.__initialized:
57
+ return
58
+ self.__initialized = True
59
+
38
60
  if context.velox_on_save_result_map is None:
39
61
  raise SapioException("No Velox on save rule result map in context for OnSaveRuleHandler to parse.")
40
62
  self.__context = context
@@ -51,9 +73,6 @@ class OnSaveRuleHandler:
51
73
  self.__base_id_to_records = {}
52
74
  # Each record ID in the context has a list of results for that record.
53
75
  for record_id, rule_results in self.__context.velox_on_save_result_map.items():
54
- # TODO: Record IDs are currently being stored in the map as strings instead of ints. This can be removed
55
- # once sapiopylib is fixed.
56
- record_id = int(record_id)
57
76
  # Keep track of the records for this specific record ID.
58
77
  id_dict: dict[str, set[DataRecord]] = {}
59
78
  # The list of results for a record consist of a list of data records and a VeloxType that specifies
@@ -64,13 +83,8 @@ class OnSaveRuleHandler:
64
83
  # Get the data type of this record. If this is an ELN type, ignore the digits.
65
84
  data_type: str = record.data_type_name
66
85
  # PR-46331: Ensure that all ELN types are converted to their base data type name.
67
- # TODO: Use ElnBaseDataType.is_eln_type when it is no longer bugged in sapiopylib.
68
- if data_type.startswith("ELNExperiment_"):
69
- data_type = "ELNExperiment"
70
- elif data_type.startswith("ELNExperimentDetail_"):
71
- data_type = "ELNExperimentDetail"
72
- elif data_type.startswith("ELNSampleDetail_"):
73
- data_type = "ELNSampleDetail"
86
+ if ElnBaseDataType.is_eln_type(data_type):
87
+ data_type = ElnBaseDataType.get_base_type(data_type).data_type_name
74
88
  # Update the list of records of this type that exist so far globally.
75
89
  self.__records.setdefault(data_type, set()).add(record)
76
90
  # Do the same for the list of records of this type that relate to this record ID.
@@ -82,24 +96,11 @@ class OnSaveRuleHandler:
82
96
  self.__base_id_to_field_maps = {}
83
97
  # Repeat the same thing for the field map results.
84
98
  for record_id, rule_results in self.__context.velox_on_save_field_map_result_map.items():
85
- # TODO: Record IDs are currently being stored in the map as strings instead of ints. This can be removed
86
- # once sapiopylib is fixed.
87
- record_id = int(record_id)
88
99
  id_dict: dict[str, dict[int, FieldMap]] = {}
89
100
  for record_result in rule_results:
90
- # TODO: sapiopylib currently has a bug where this velox_type_pojo variable is stored as a dict instead
91
- # of as the intended VeloxRuleType object. Parse that dict as a VeloxRuleType before use.
92
- velox_type: VeloxRuleType | dict = record_result.velox_type_pojo
93
- if isinstance(velox_type, dict):
94
- velox_type: VeloxRuleType = VeloxRuleParser.parse_velox_rule_type(velox_type)
95
- data_type: str = velox_type.data_type_name
96
- # TODO: Use ElnBaseDataType.is_eln_type when it is no longer bugged in sapiopylib.
97
- if data_type.startswith("ELNExperiment_"):
98
- data_type = "ELNExperiment"
99
- elif data_type.startswith("ELNExperimentDetail_"):
100
- data_type = "ELNExperimentDetail"
101
- elif data_type.startswith("ELNSampleDetail_"):
102
- data_type = "ELNSampleDetail"
101
+ data_type: str = record_result.velox_type_pojo.data_type_name
102
+ if ElnBaseDataType.is_eln_type(data_type):
103
+ data_type = ElnBaseDataType.get_base_type(data_type).data_type_name
103
104
  for field_map in record_result.field_map_list:
104
105
  rec_id: int = field_map.get("RecordId")
105
106
  self.__field_maps.setdefault(data_type, {}).update({rec_id: field_map})
@@ -4,16 +4,25 @@ from logging import Logger
4
4
 
5
5
  from sapiopylib.rest.DataMgmtService import DataMgmtServer
6
6
  from sapiopylib.rest.DataRecordManagerService import DataRecordManager
7
+ from sapiopylib.rest.User import SapioUser
7
8
  from sapiopylib.rest.WebhookService import AbstractWebhookHandler
9
+ from sapiopylib.rest.pojo.Message import VeloxLogMessage, VeloxLogLevel
8
10
  from sapiopylib.rest.pojo.webhook.WebhookContext import SapioWebhookContext
9
11
  from sapiopylib.rest.pojo.webhook.WebhookEnums import WebhookEndpointType
10
12
  from sapiopylib.rest.pojo.webhook.WebhookResult import SapioWebhookResult
13
+ from sapiopylib.rest.utils.DataTypeCacheManager import DataTypeCacheManager
11
14
  from sapiopylib.rest.utils.recordmodel.RecordModelManager import RecordModelManager, RecordModelInstanceManager, \
12
15
  RecordModelRelationshipManager
13
16
  from sapiopylib.rest.utils.recordmodel.ancestry import RecordModelAncestorManager
14
17
 
18
+ from sapiopycommons.callbacks.callback_util import CallbackUtil
19
+ from sapiopycommons.eln.experiment_handler import ExperimentHandler
15
20
  from sapiopycommons.general.exceptions import SapioUserErrorException, SapioCriticalErrorException, \
16
- SapioUserCancelledException
21
+ SapioUserCancelledException, SapioException
22
+ from sapiopycommons.general.sapio_links import SapioNavigationLinker
23
+ from sapiopycommons.recordmodel.record_handler import RecordHandler
24
+ from sapiopycommons.rules.eln_rule_handler import ElnRuleHandler
25
+ from sapiopycommons.rules.on_save_rule_handler import OnSaveRuleHandler
17
26
 
18
27
 
19
28
  # FR-46064 - Initial port of PyWebhookUtils to sapiopycommons.
@@ -25,6 +34,7 @@ class CommonsWebhookHandler(AbstractWebhookHandler):
25
34
  """
26
35
  logger: Logger
27
36
 
37
+ user: SapioUser
28
38
  context: SapioWebhookContext
29
39
 
30
40
  dr_man: DataRecordManager
@@ -34,21 +44,46 @@ class CommonsWebhookHandler(AbstractWebhookHandler):
34
44
  # FR-46329: Add the ancestor manager to CommonsWebhookHandler.
35
45
  an_man: RecordModelAncestorManager
36
46
 
47
+ dt_cache: DataTypeCacheManager
48
+ rec_handler: RecordHandler
49
+ callback: CallbackUtil
50
+ exp_handler: ExperimentHandler | None
51
+ rule_handler: OnSaveRuleHandler | ElnRuleHandler | None
52
+
37
53
  def run(self, context: SapioWebhookContext) -> SapioWebhookResult:
54
+ self.user = context.user
38
55
  self.context = context
39
- self.logger = context.user.logger
56
+
57
+ self.logger = self.user.logger
40
58
 
41
59
  self.dr_man = context.data_record_manager
42
- self.rec_man = RecordModelManager(context.user)
60
+ self.rec_man = RecordModelManager(self.user)
43
61
  self.inst_man = self.rec_man.instance_manager
44
62
  self.rel_man = self.rec_man.relationship_manager
45
63
  self.an_man = RecordModelAncestorManager(self.rec_man)
46
64
 
65
+ self.dt_cache = DataTypeCacheManager(self.user)
66
+ self.rec_handler = RecordHandler(context)
67
+ self.callback = CallbackUtil(context)
68
+ if context.eln_experiment is not None:
69
+ self.exp_handler = ExperimentHandler(context)
70
+ else:
71
+ self.exp_handler = None
72
+ if self.is_on_save_rule():
73
+ self.rule_handler = OnSaveRuleHandler(context)
74
+ elif self.is_eln_rule():
75
+ self.rule_handler = ElnRuleHandler(context)
76
+ else:
77
+ self.rule_handler = None
78
+
47
79
  # Wrap the execution of each webhook in a try/catch. If an exception occurs, handle any special sapiopycommons
48
80
  # exceptions. Otherwise, return a generic message stating that an error occurred.
49
81
  try:
50
82
  self.initialize(context)
51
- return self.execute(context)
83
+ result = self.execute(context)
84
+ if result is None:
85
+ raise SapioException("Your execute function returned a None result! Don't forget your return statement!")
86
+ return result
52
87
  except SapioUserErrorException as e:
53
88
  return self.handle_user_error_exception(e)
54
89
  except SapioCriticalErrorException as e:
@@ -100,7 +135,7 @@ class CommonsWebhookHandler(AbstractWebhookHandler):
100
135
  return result
101
136
  self.log_error(traceback.format_exc())
102
137
  if self.can_send_client_callback():
103
- DataMgmtServer.get_client_callback(self.context.user).display_error(e.args[0])
138
+ DataMgmtServer.get_client_callback(self.user).display_error(e.args[0])
104
139
  return SapioWebhookResult(False)
105
140
 
106
141
  def handle_unexpected_exception(self, e: Exception) -> SapioWebhookResult:
@@ -114,7 +149,10 @@ class CommonsWebhookHandler(AbstractWebhookHandler):
114
149
  result: SapioWebhookResult | None = self.handle_any_exception(e)
115
150
  if result is not None:
116
151
  return result
117
- self.log_error(traceback.format_exc())
152
+ msg: str = traceback.format_exc()
153
+ self.log_error(msg)
154
+ # FR-47079: Also log all unexpected exception messages to the webhook execution log within the platform.
155
+ self.log_error_to_webhook_execution_log(msg)
118
156
  return SapioWebhookResult(False, display_text="Unexpected error occurred during webhook execution. "
119
157
  "Please contact Sapio support.")
120
158
 
@@ -129,7 +167,7 @@ class CommonsWebhookHandler(AbstractWebhookHandler):
129
167
  result: SapioWebhookResult | None = self.handle_any_exception(e)
130
168
  if result is not None:
131
169
  return result
132
- return SapioWebhookResult(False, display_text="User cancelled.")
170
+ return SapioWebhookResult(False)
133
171
 
134
172
  # noinspection PyMethodMayBeStatic,PyUnusedLocal
135
173
  def handle_any_exception(self, e: Exception) -> SapioWebhookResult | None:
@@ -145,32 +183,58 @@ class CommonsWebhookHandler(AbstractWebhookHandler):
145
183
 
146
184
  def log_info(self, msg: str) -> None:
147
185
  """
148
- Write an info message to the log. Log destination is stdout. This message will be prepended with the user's
149
- username and the experiment ID of the experiment they are in, if any.
186
+ Write an info message to the webhook server log. Log destination is stdout. This message will be prepended with
187
+ the user's username and the experiment ID of the experiment they are in, if any.
150
188
  """
151
- exp_id = None
152
- if self.context.eln_experiment is not None:
153
- exp_id = self.context.eln_experiment.notebook_experiment_id
154
- # CR-46333: Add the user's group to the logging message.
155
- user = self.context.user
156
- username = user.username
157
- group_name = user.session_additional_data.current_group_name
158
- self.logger.info(f"(User: {username}, Group: {group_name}, Experiment: {exp_id}):\n{msg}")
189
+ self.logger.info(self._format_log(msg, "log_info call"))
159
190
 
160
191
  def log_error(self, msg: str) -> None:
161
192
  """
162
- Write an error message to the log. Log destination is stderr. This message will be prepended with the user's
163
- username and the experiment ID of the experiment they are in, if any.
193
+ Write an error message to the webhook server log. Log destination is stderr. This message will be prepended with
194
+ the user's username and the experiment ID of the experiment they are in, if any.
164
195
  """
165
- exp_id = None
196
+ # PR-46209: Use logger.error instead of logger.info when logging errors.
197
+ self.logger.error(self._format_log(msg, "log_error call"))
198
+
199
+ def log_error_to_webhook_execution_log(self, msg: str) -> None:
200
+ """
201
+ Write an error message to the platform's webhook execution log. This can be reviewed by navigating to the
202
+ webhook configuration where the webhook that called this function is defined and clicking the "View Log"
203
+ button. From there, select one of the rows for the webhook executions and click "Download Log" from the right
204
+ side table.
205
+ """
206
+ messenger = DataMgmtServer.get_messenger(self.user)
207
+ messenger.log_message(VeloxLogMessage(message=self._format_log(msg, "Error occurred during webhook execution."),
208
+ log_level=VeloxLogLevel.ERROR,
209
+ originating_class=self.__class__.__name__))
210
+
211
+ def _format_log(self, msg: str, prefix: str | None = None) -> str:
212
+ """
213
+ Given a message to log, populate it with some metadata about this particular webhook execution, including
214
+ the group of the user and the invocation type of the webhook call.
215
+ """
216
+ # If we're able to, provide a link to the location that the error occurred at.
217
+ navigator = SapioNavigationLinker(self.context)
166
218
  if self.context.eln_experiment is not None:
167
- exp_id = self.context.eln_experiment.notebook_experiment_id
219
+ link = navigator.experiment(self.context.eln_experiment)
220
+ elif self.context.data_record and not self.context.data_record_list:
221
+ link = navigator.data_record(self.context.data_record)
222
+ elif self.context.base_data_record:
223
+ link = navigator.data_record(self.context.base_data_record)
224
+ else:
225
+ link = None
226
+
227
+ message: str = ""
228
+ if prefix:
229
+ message += prefix + "\n"
230
+ message += f"Webhook invocation type: {self.context.end_point_type.display_name}\n"
231
+ message += f"Username: {self.user.username}\n"
168
232
  # CR-46333: Add the user's group to the logging message.
169
- user = self.context.user
170
- username = user.username
171
- group_name = user.session_additional_data.current_group_name
172
- # PR-46209: Use logger.error instead of logger.info when logging errors.
173
- self.logger.error(f"(User: {username}, Group: {group_name}, Experiment: {exp_id}):\n{msg}")
233
+ message += f"User group: {self.user.session_additional_data.current_group_name}\n"
234
+ if link:
235
+ message += f"User location: {link}\n"
236
+ message += msg
237
+ return message
174
238
 
175
239
  def is_main_toolbar(self) -> bool:
176
240
  """