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.
- sapiopycommons/callbacks/callback_util.py +122 -25
- sapiopycommons/customreport/__init__.py +0 -0
- sapiopycommons/customreport/column_builder.py +60 -0
- sapiopycommons/customreport/custom_report_builder.py +125 -0
- sapiopycommons/customreport/term_builder.py +296 -0
- sapiopycommons/datatype/attachment_util.py +15 -6
- sapiopycommons/eln/experiment_handler.py +193 -39
- sapiopycommons/files/complex_data_loader.py +1 -1
- sapiopycommons/files/file_bridge.py +1 -1
- sapiopycommons/files/file_bridge_handler.py +21 -0
- sapiopycommons/files/file_util.py +38 -5
- sapiopycommons/files/file_validator.py +21 -6
- sapiopycommons/files/file_writer.py +44 -15
- sapiopycommons/general/aliases.py +93 -2
- sapiopycommons/general/audit_log.py +200 -0
- sapiopycommons/general/popup_util.py +17 -0
- sapiopycommons/general/sapio_links.py +48 -0
- sapiopycommons/general/time_util.py +40 -0
- sapiopycommons/recordmodel/record_handler.py +114 -17
- sapiopycommons/rules/eln_rule_handler.py +29 -22
- sapiopycommons/rules/on_save_rule_handler.py +29 -28
- sapiopycommons/webhook/webhook_handlers.py +90 -26
- sapiopycommons/webhook/webservice_handlers.py +67 -0
- {sapiopycommons-2024.8.15a304.dist-info → sapiopycommons-2024.8.19a305.dist-info}/METADATA +1 -1
- sapiopycommons-2024.8.19a305.dist-info/RECORD +50 -0
- sapiopycommons-2024.8.15a304.dist-info/RECORD +0 -43
- {sapiopycommons-2024.8.15a304.dist-info → sapiopycommons-2024.8.19a305.dist-info}/WHEEL +0 -0
- {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,
|
|
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
|
-
|
|
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(
|
|
892
|
+
current = current.get_child_of_type(data_type)
|
|
853
893
|
elif direction == RelationshipNodeType.PARENT:
|
|
854
|
-
current = current.get_parent_of_type(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
1003
|
+
current = current[0].get_children_of_type(data_type)
|
|
930
1004
|
elif direction == RelationshipNodeType.PARENT:
|
|
931
|
-
current = current[0].get_parents_of_type(
|
|
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.
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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.
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
56
|
+
|
|
57
|
+
self.logger = self.user.logger
|
|
40
58
|
|
|
41
59
|
self.dr_man = context.data_record_manager
|
|
42
|
-
self.rec_man = RecordModelManager(
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
"""
|