sapiopycommons 2024.8.28a313__py3-none-any.whl → 2024.8.28a314__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 +277 -35
- sapiopycommons/chem/IndigoMolecules.py +1 -0
- sapiopycommons/chem/Molecules.py +1 -0
- sapiopycommons/eln/experiment_report_util.py +214 -0
- sapiopycommons/files/file_bridge.py +16 -10
- sapiopycommons/files/file_bridge_handler.py +318 -0
- sapiopycommons/files/file_util.py +13 -6
- sapiopycommons/files/file_validator.py +71 -0
- sapiopycommons/general/accession_service.py +375 -0
- sapiopycommons/general/custom_report_util.py +199 -27
- sapiopycommons/multimodal/multimodal.py +146 -0
- sapiopycommons/multimodal/multimodal_data.py +487 -0
- sapiopycommons/recordmodel/record_handler.py +278 -45
- sapiopycommons/webhook/webhook_handlers.py +58 -1
- {sapiopycommons-2024.8.28a313.dist-info → sapiopycommons-2024.8.28a314.dist-info}/METADATA +4 -2
- {sapiopycommons-2024.8.28a313.dist-info → sapiopycommons-2024.8.28a314.dist-info}/RECORD +18 -13
- {sapiopycommons-2024.8.28a313.dist-info → sapiopycommons-2024.8.28a314.dist-info}/WHEEL +0 -0
- {sapiopycommons-2024.8.28a313.dist-info → sapiopycommons-2024.8.28a314.dist-info}/licenses/LICENSE +0 -0
|
@@ -3,8 +3,10 @@ from typing import Any
|
|
|
3
3
|
|
|
4
4
|
from sapiopylib.rest.DataRecordManagerService import DataRecordManager
|
|
5
5
|
from sapiopylib.rest.User import SapioUser
|
|
6
|
+
from sapiopylib.rest.pojo.CustomReport import CustomReportCriteria, RawReportTerm, ReportColumn
|
|
6
7
|
from sapiopylib.rest.pojo.DataRecord import DataRecord
|
|
7
8
|
from sapiopylib.rest.pojo.DataRecordPaging import DataRecordPojoPageCriteria
|
|
9
|
+
from sapiopylib.rest.pojo.datatype.FieldDefinition import FieldType
|
|
8
10
|
from sapiopylib.rest.pojo.webhook.WebhookContext import SapioWebhookContext
|
|
9
11
|
from sapiopylib.rest.utils.autopaging import QueryDataRecordsAutoPager, QueryDataRecordByIdListAutoPager, \
|
|
10
12
|
QueryAllRecordsOfTypeAutoPager
|
|
@@ -75,6 +77,43 @@ class RecordHandler:
|
|
|
75
77
|
"""
|
|
76
78
|
return self.query_models_with_criteria(wrapper_type, field, value_list, None, page_limit)[0]
|
|
77
79
|
|
|
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]]:
|
|
83
|
+
"""
|
|
84
|
+
Shorthand for using query_models to search for records given values on a specific field and then using
|
|
85
|
+
map_by_field to turn the returned list into a dictionary mapping field values to records.
|
|
86
|
+
|
|
87
|
+
:param wrapper_type: The record model wrapper to use.
|
|
88
|
+
:param field: The field to query and map on.
|
|
89
|
+
:param value_list: The values of the field to query on.
|
|
90
|
+
:param page_limit: The maximum number of pages to query. If None, exhausts all possible pages.
|
|
91
|
+
:param mapping_field: If provided, use this field to map against instead of the field that was queried on.
|
|
92
|
+
:return: The record models for the queried records mapped by field values to the records with that value.
|
|
93
|
+
"""
|
|
94
|
+
if mapping_field is None:
|
|
95
|
+
mapping_field = field
|
|
96
|
+
return self.map_by_field(self.query_models(wrapper_type, field, value_list, page_limit), mapping_field)
|
|
97
|
+
|
|
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]:
|
|
101
|
+
"""
|
|
102
|
+
Shorthand for using query_models to search for records given values on a specific field and then using
|
|
103
|
+
map_by_unique_field to turn the returned list into a dictionary mapping field values to records.
|
|
104
|
+
If any two records share the same field value, throws an exception.
|
|
105
|
+
|
|
106
|
+
:param wrapper_type: The record model wrapper to use.
|
|
107
|
+
:param field: The field to query and map on.
|
|
108
|
+
:param value_list: The values of the field to query on.
|
|
109
|
+
:param page_limit: The maximum number of pages to query. If None, exhausts all possible pages.
|
|
110
|
+
:param mapping_field: If provided, use this field to map against instead of the field that was queried on.
|
|
111
|
+
:return: The record models for the queried records mapped by field values to the record with that value.
|
|
112
|
+
"""
|
|
113
|
+
if mapping_field is None:
|
|
114
|
+
mapping_field = field
|
|
115
|
+
return self.map_by_unique_field(self.query_models(wrapper_type, field, value_list, page_limit), mapping_field)
|
|
116
|
+
|
|
78
117
|
def query_models_with_criteria(self, wrapper_type: type[WrappedType], field: str, value_list: Iterable[Any],
|
|
79
118
|
paging_criteria: DataRecordPojoPageCriteria | None = None,
|
|
80
119
|
page_limit: int | None = None) \
|
|
@@ -160,24 +199,58 @@ class RecordHandler:
|
|
|
160
199
|
return self.wrap_models(pager.get_all_at_once(), wrapper_type), pager.next_page_criteria
|
|
161
200
|
|
|
162
201
|
def query_models_by_report(self, wrapper_type: type[WrappedType],
|
|
163
|
-
report_name: str,
|
|
202
|
+
report_name: str | RawReportTerm | CustomReportCriteria,
|
|
164
203
|
filters: dict[str, Iterable[Any]] | None = None,
|
|
165
|
-
page_limit: int | None = None
|
|
204
|
+
page_limit: int | None = None,
|
|
205
|
+
page_size: int | None = None,
|
|
206
|
+
page_number: int | None = None) -> list[WrappedType]:
|
|
166
207
|
"""
|
|
167
|
-
Run a
|
|
168
|
-
First runs the
|
|
208
|
+
Run a report and use the results of that report to query for and return the records in the report results.
|
|
209
|
+
First runs the report, then runs a data record manager query on the results of the custom report.
|
|
210
|
+
|
|
211
|
+
Will throw an exception if given the name of a system report that does not have a RecordId column.
|
|
212
|
+
Quick and custom reports are guaranteed to have a record ID column.
|
|
169
213
|
|
|
170
|
-
|
|
214
|
+
Any given custom report criteria should only have columns from a single data type.
|
|
171
215
|
|
|
172
216
|
:param wrapper_type: The record model wrapper to use.
|
|
173
|
-
:param report_name: The name of
|
|
217
|
+
:param report_name: The name of a system report, or a raw report term for a quick report, or custom report
|
|
218
|
+
criteria for a custom report.
|
|
174
219
|
:param filters: If provided, filter the results of the report using the given mapping of headers to values to
|
|
175
220
|
filter on. This filtering is done before the records are queried.
|
|
176
221
|
:param page_limit: The maximum number of pages to query. If None, exhausts all possible pages.
|
|
177
|
-
:
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
222
|
+
:param page_size: The size of each page of results in the search. If None, the page size is set by the server.
|
|
223
|
+
If the input report is a custom report criteria, uses the value from the criteria, unless this value is
|
|
224
|
+
not None, in which case it overwrites the given report's value.
|
|
225
|
+
:param page_number: The page number to start the search from, If None, starts on the first page.
|
|
226
|
+
If the input report is a custom report criteria, uses the value from the criteria, unless this value is
|
|
227
|
+
not None, in which case it overwrites the given report's value.
|
|
228
|
+
:return: The record models for the queried records that matched the given report.
|
|
229
|
+
"""
|
|
230
|
+
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)
|
|
233
|
+
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)
|
|
236
|
+
elif isinstance(report_name, CustomReportCriteria):
|
|
237
|
+
dt: str = wrapper_type.get_wrapper_data_type_name()
|
|
238
|
+
# Ensure that the root data type is the one we're looking for.
|
|
239
|
+
report_name.root_data_type = dt
|
|
240
|
+
# Raise an exception if any column in the report doesn't match the given data type.
|
|
241
|
+
if any([x.data_type_name != dt for x in report_name.column_list]):
|
|
242
|
+
raise SapioException("You may only query records from a report containing columns from that data type.")
|
|
243
|
+
# Enforce that the given custom report has a record ID column.
|
|
244
|
+
if not any([x.data_type_name == dt and x.data_field_name == "RecordId" for x in report_name.column_list]):
|
|
245
|
+
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)
|
|
248
|
+
else:
|
|
249
|
+
raise SapioException("Unrecognized report object.")
|
|
250
|
+
|
|
251
|
+
# Using the bracket accessor because we want to throw an exception if RecordId doesn't exist in the report.
|
|
252
|
+
# This should only possibly be the case with system reports, as quick reports will include the record ID and
|
|
253
|
+
# we forced any given custom report to have a record ID column.
|
|
181
254
|
ids: list[int] = [row["RecordId"] for row in results]
|
|
182
255
|
return self.query_models_by_id(wrapper_type, ids)
|
|
183
256
|
|
|
@@ -211,10 +284,46 @@ class RecordHandler:
|
|
|
211
284
|
the fields in the fields list.
|
|
212
285
|
"""
|
|
213
286
|
models: list[WrappedType] = self.add_models(wrapper_type, len(fields))
|
|
214
|
-
for model,
|
|
215
|
-
model.set_field_values(
|
|
287
|
+
for model, field_list in zip(models, fields):
|
|
288
|
+
model.set_field_values(field_list)
|
|
216
289
|
return models
|
|
217
290
|
|
|
291
|
+
def find_or_add_model(self, wrapper_type: type[WrappedType], primary_identifier: str, id_value: Any,
|
|
292
|
+
secondary_identifiers: FieldMap | None = None) -> WrappedType:
|
|
293
|
+
"""
|
|
294
|
+
Find a unique record that matches the given field values. If no such records exist, add a record model to the
|
|
295
|
+
cache with the identifying fields set to the desired values. This record will be created in the system when
|
|
296
|
+
you store and commit changes. If more than one record with the identifying values exists, throws an exception.
|
|
297
|
+
|
|
298
|
+
The record is searched for using the primary identifier field name and value. If multiple records are returned
|
|
299
|
+
by the query on this primary identifier, then the secondary identifiers are used to filter the results.
|
|
300
|
+
|
|
301
|
+
Makes a webservice call to query for the existing record.
|
|
302
|
+
|
|
303
|
+
:param wrapper_type: The record model wrapper to use.
|
|
304
|
+
:param primary_identifier: The data field name of the field to search on.
|
|
305
|
+
:param id_value: The value of the identifying field to search for.
|
|
306
|
+
:param secondary_identifiers: Optional fields used to filter the records that are returned after searching on
|
|
307
|
+
the primary identifier.
|
|
308
|
+
:return: The record model with the identifying field value, either pulled from the system or newly created.
|
|
309
|
+
"""
|
|
310
|
+
# PR-46335: Initialize the secondary identifiers parameter if None is provided to avoid an exception.
|
|
311
|
+
# If no secondary identifiers were provided, use an empty dictionary.
|
|
312
|
+
if secondary_identifiers is None:
|
|
313
|
+
secondary_identifiers = {}
|
|
314
|
+
|
|
315
|
+
unique_record: WrappedType | None = self.__find_model(wrapper_type, primary_identifier, id_value,
|
|
316
|
+
secondary_identifiers)
|
|
317
|
+
# If a unique record matched the identifiers, return it.
|
|
318
|
+
if unique_record is not None:
|
|
319
|
+
return unique_record
|
|
320
|
+
|
|
321
|
+
# If none of the results matched the identifiers, create a new record with all identifiers set.
|
|
322
|
+
# Put the primary identifier and value into the secondary identifiers list and use that as the fields map
|
|
323
|
+
# for this new record.
|
|
324
|
+
secondary_identifiers.update({primary_identifier: id_value})
|
|
325
|
+
return self.add_models_with_data(wrapper_type, [secondary_identifiers])[0]
|
|
326
|
+
|
|
218
327
|
def create_models(self, wrapper_type: type[WrappedType], num: int) -> list[WrappedType]:
|
|
219
328
|
"""
|
|
220
329
|
Shorthand for creating new records via the data record manager and then returning them as wrapped
|
|
@@ -270,24 +379,8 @@ class RecordHandler:
|
|
|
270
379
|
if secondary_identifiers is None:
|
|
271
380
|
secondary_identifiers = {}
|
|
272
381
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
# Find the one record, if any, that matches the secondary identifiers.
|
|
277
|
-
unique_record: WrappedType | None = None
|
|
278
|
-
for result in results:
|
|
279
|
-
matches_all: bool = True
|
|
280
|
-
for field, value in secondary_identifiers.items():
|
|
281
|
-
if result.get_field_value(field) != value:
|
|
282
|
-
matches_all = False
|
|
283
|
-
break
|
|
284
|
-
if matches_all:
|
|
285
|
-
# If a previous record in the results already matched all identifiers, then throw an exception.
|
|
286
|
-
if unique_record is not None:
|
|
287
|
-
raise SapioException(f"More than one record of type {wrapper_type.get_wrapper_data_type_name()} "
|
|
288
|
-
f"encountered in system that matches all provided identifiers.")
|
|
289
|
-
unique_record = result
|
|
290
|
-
|
|
382
|
+
unique_record: WrappedType | None = self.__find_model(wrapper_type, primary_identifier, id_value,
|
|
383
|
+
secondary_identifiers)
|
|
291
384
|
# If a unique record matched the identifiers, return it.
|
|
292
385
|
if unique_record is not None:
|
|
293
386
|
return unique_record
|
|
@@ -329,6 +422,29 @@ class RecordHandler:
|
|
|
329
422
|
return_dict[model] = model.get_parents_of_type(parent_type)
|
|
330
423
|
return return_dict
|
|
331
424
|
|
|
425
|
+
@staticmethod
|
|
426
|
+
def map_by_parent(models: Iterable[RecordModel], parent_type: type[WrappedType]) \
|
|
427
|
+
-> dict[WrappedType, RecordModel]:
|
|
428
|
+
"""
|
|
429
|
+
Take a list of record models and map them by their parent. Essentially an inversion of map_to_parent.
|
|
430
|
+
If two records share the same parent, an exception will be thrown. The parents must already be loaded.
|
|
431
|
+
|
|
432
|
+
:param models: A list of record models.
|
|
433
|
+
:param parent_type: The record model wrapper of the parents.
|
|
434
|
+
:return: A dict[ParentType, ModelType]. If an input model doesn't have a parent of the given parent type,
|
|
435
|
+
then it will not be in the resulting dictionary.
|
|
436
|
+
"""
|
|
437
|
+
to_parent: dict[RecordModel, WrappedType] = RecordHandler.map_to_parent(models, parent_type)
|
|
438
|
+
by_parent: dict[WrappedType, RecordModel] = {}
|
|
439
|
+
for record, parent in to_parent.items():
|
|
440
|
+
if parent is None:
|
|
441
|
+
continue
|
|
442
|
+
if parent in by_parent:
|
|
443
|
+
raise SapioException(f"Parent {parent.data_type_name} {parent.record_id} encountered more than once "
|
|
444
|
+
f"in models list.")
|
|
445
|
+
by_parent[parent] = record
|
|
446
|
+
return by_parent
|
|
447
|
+
|
|
332
448
|
@staticmethod
|
|
333
449
|
def map_by_parents(models: Iterable[RecordModel], parent_type: type[WrappedType]) \
|
|
334
450
|
-> dict[WrappedType, list[RecordModel]]:
|
|
@@ -379,6 +495,29 @@ class RecordHandler:
|
|
|
379
495
|
return_dict[model] = model.get_children_of_type(child_type)
|
|
380
496
|
return return_dict
|
|
381
497
|
|
|
498
|
+
@staticmethod
|
|
499
|
+
def map_by_child(models: Iterable[RecordModel], child_type: type[WrappedType]) \
|
|
500
|
+
-> dict[WrappedType, list[RecordModel]]:
|
|
501
|
+
"""
|
|
502
|
+
Take a list of record models and map them by their children. Essentially an inversion of map_to_child.
|
|
503
|
+
If two records share the same child, an exception will be thrown. The children must already be loaded.
|
|
504
|
+
|
|
505
|
+
:param models: A list of record models.
|
|
506
|
+
:param child_type: The record model wrapper of the children.
|
|
507
|
+
:return: A dict[ChildType, ModelType]. If an input model doesn't have a child of the given child type,
|
|
508
|
+
then it will not be in the resulting dictionary.
|
|
509
|
+
"""
|
|
510
|
+
to_child: dict[RecordModel, WrappedType] = RecordHandler.map_to_child(models, child_type)
|
|
511
|
+
by_child: dict[WrappedType, RecordModel] = {}
|
|
512
|
+
for record, child in to_child.items():
|
|
513
|
+
if child is None:
|
|
514
|
+
continue
|
|
515
|
+
if child in by_child:
|
|
516
|
+
raise SapioException(f"Child {child.data_type_name} {child.record_id} encountered more than once "
|
|
517
|
+
f"in models list.")
|
|
518
|
+
by_child[child] = record
|
|
519
|
+
return by_child
|
|
520
|
+
|
|
382
521
|
@staticmethod
|
|
383
522
|
def map_by_children(models: Iterable[RecordModel], child_type: type[WrappedType]) \
|
|
384
523
|
-> dict[WrappedType, list[RecordModel]]:
|
|
@@ -416,8 +555,8 @@ class RecordHandler:
|
|
|
416
555
|
return return_dict
|
|
417
556
|
|
|
418
557
|
@staticmethod
|
|
419
|
-
def
|
|
420
|
-
|
|
558
|
+
def map_by_forward_side_links(models: Iterable[WrappedRecordModel], field_name: str,
|
|
559
|
+
side_link_type: type[WrappedType]) -> dict[WrappedType, list[WrappedRecordModel]]:
|
|
421
560
|
"""
|
|
422
561
|
Take a list of record models and map them by their forward side link. Essentially an inversion of
|
|
423
562
|
map_to_forward_side_link. Input models that share a forward side link will end up in the same list.
|
|
@@ -429,18 +568,44 @@ class RecordHandler:
|
|
|
429
568
|
:return: A dict[SideLink, list[ModelType]]. If an input model doesn't have a forward side link of the given type
|
|
430
569
|
pointing to it, then it will not be in the resulting dictionary.
|
|
431
570
|
"""
|
|
432
|
-
to_side_link: dict[
|
|
571
|
+
to_side_link: dict[WrappedRecordModel, WrappedType] = RecordHandler\
|
|
433
572
|
.map_to_forward_side_link(models, field_name, side_link_type)
|
|
434
|
-
by_side_link: dict[WrappedType, list[
|
|
573
|
+
by_side_link: dict[WrappedType, list[WrappedRecordModel]] = {}
|
|
435
574
|
for record, side_link in to_side_link.items():
|
|
436
575
|
if side_link is None:
|
|
437
576
|
continue
|
|
438
577
|
by_side_link.setdefault(side_link, []).append(record)
|
|
439
578
|
return by_side_link
|
|
440
579
|
|
|
580
|
+
@staticmethod
|
|
581
|
+
def map_by_forward_side_link(models: Iterable[WrappedRecordModel], field_name: str,
|
|
582
|
+
side_link_type: type[WrappedType]) -> dict[WrappedType, WrappedRecordModel]:
|
|
583
|
+
"""
|
|
584
|
+
Take a list of record models and map them by their forward side link. Essentially an inversion of
|
|
585
|
+
map_to_forward_side_link, but if two records share the same forward link, an exception is thrown.
|
|
586
|
+
The forward side link must already be loaded.
|
|
587
|
+
|
|
588
|
+
:param models: A list of record models.
|
|
589
|
+
:param field_name: The field name on the record models where the side link is located.
|
|
590
|
+
:param side_link_type: The record model wrapper of the forward side links.
|
|
591
|
+
:return: A dict[SideLink, ModelType]. If an input model doesn't have a forward side link of the given type
|
|
592
|
+
pointing to it, then it will not be in the resulting dictionary.
|
|
593
|
+
"""
|
|
594
|
+
to_side_link: dict[WrappedRecordModel, WrappedType] = RecordHandler\
|
|
595
|
+
.map_to_forward_side_link(models, field_name, side_link_type)
|
|
596
|
+
by_side_link: dict[WrappedType, WrappedRecordModel] = {}
|
|
597
|
+
for record, side_link in to_side_link.items():
|
|
598
|
+
if side_link is None:
|
|
599
|
+
continue
|
|
600
|
+
if side_link in by_side_link:
|
|
601
|
+
raise SapioException(f"Side link {side_link.data_type_name} {side_link.record_id} encountered more "
|
|
602
|
+
f"than once in models list.")
|
|
603
|
+
by_side_link[side_link] = record
|
|
604
|
+
return by_side_link
|
|
605
|
+
|
|
441
606
|
@staticmethod
|
|
442
607
|
def map_to_reverse_side_links(models: Iterable[WrappedRecordModel], field_name: str,
|
|
443
|
-
side_link_type: type[WrappedType]) -> dict[
|
|
608
|
+
side_link_type: type[WrappedType]) -> dict[WrappedRecordModel, list[WrappedType]]:
|
|
444
609
|
"""
|
|
445
610
|
Map a list of record models to a list reverse side links of a given type. The reverse side links must already
|
|
446
611
|
be loaded.
|
|
@@ -457,6 +622,29 @@ class RecordHandler:
|
|
|
457
622
|
return_dict[model] = model.get_reverse_side_link(field_name, side_link_type)
|
|
458
623
|
return return_dict
|
|
459
624
|
|
|
625
|
+
@staticmethod
|
|
626
|
+
def map_to_reverse_side_link(models: Iterable[WrappedRecordModel], field_name: str,
|
|
627
|
+
side_link_type: type[WrappedType]) -> dict[WrappedRecordModel, WrappedType]:
|
|
628
|
+
"""
|
|
629
|
+
Map a list of record models to the reverse side link of a given type. If a given record has more than one
|
|
630
|
+
reverse side link of this type, an exception is thrown. The reverse side links must already be loaded.
|
|
631
|
+
|
|
632
|
+
:param models: A list of record models.
|
|
633
|
+
:param field_name: The field name on the side linked model where the side link to the given record models is
|
|
634
|
+
located.
|
|
635
|
+
:param side_link_type: The record model wrapper of the reverse side links.
|
|
636
|
+
:return: A dict[ModelType, SideLink]. If an input model doesn't have reverse side links of the given type,
|
|
637
|
+
then it will map to None.
|
|
638
|
+
"""
|
|
639
|
+
return_dict: dict[WrappedRecordModel, WrappedType] = {}
|
|
640
|
+
for model in models:
|
|
641
|
+
links: list[WrappedType] = model.get_reverse_side_link(field_name, side_link_type)
|
|
642
|
+
if len(links) > 1:
|
|
643
|
+
raise SapioException(f"Model {model.data_type_name} {model.record_id} has more than one reverse link "
|
|
644
|
+
f"of type {side_link_type.get_wrapper_data_type_name()}.")
|
|
645
|
+
return_dict[model] = links[0] if links else None
|
|
646
|
+
return return_dict
|
|
647
|
+
|
|
460
648
|
@staticmethod
|
|
461
649
|
def map_by_reverse_side_links(models: Iterable[WrappedRecordModel], field_name: str,
|
|
462
650
|
side_link_type: type[WrappedType]) -> dict[WrappedType, list[WrappedRecordModel]]:
|
|
@@ -472,14 +660,41 @@ class RecordHandler:
|
|
|
472
660
|
:return: A dict[SideLink, list[ModelType]]. If an input model doesn't have reverse side links of the given type
|
|
473
661
|
pointing to it, then it will not be in the resulting dictionary.
|
|
474
662
|
"""
|
|
475
|
-
to_side_links: dict[
|
|
663
|
+
to_side_links: dict[WrappedRecordModel, list[WrappedType]] = RecordHandler\
|
|
476
664
|
.map_to_reverse_side_links(models, field_name, side_link_type)
|
|
477
|
-
by_side_links: dict[WrappedType, list[
|
|
665
|
+
by_side_links: dict[WrappedType, list[WrappedRecordModel]] = {}
|
|
478
666
|
for record, side_links in to_side_links.items():
|
|
479
667
|
for side_link in side_links:
|
|
480
668
|
by_side_links.setdefault(side_link, []).append(record)
|
|
481
669
|
return by_side_links
|
|
482
670
|
|
|
671
|
+
@staticmethod
|
|
672
|
+
def map_by_reverse_side_link(models: Iterable[WrappedRecordModel], field_name: str,
|
|
673
|
+
side_link_type: type[WrappedType]) -> dict[WrappedType, WrappedRecordModel]:
|
|
674
|
+
"""
|
|
675
|
+
Take a list of record models and map them by their reverse side link. Essentially an inversion of
|
|
676
|
+
map_to_reverse_side_link. If two records share the same reverse side link, an exception is thrown.
|
|
677
|
+
The reverse side links must already be loaded.
|
|
678
|
+
|
|
679
|
+
:param models: A list of record models.
|
|
680
|
+
:param field_name: The field name on the side linked model where the side link to the given record models is
|
|
681
|
+
located.
|
|
682
|
+
:param side_link_type: The record model wrapper of the reverse side links.
|
|
683
|
+
:return: A dict[SideLink, ModelType]. If an input model doesn't have a reverse side link of the given type
|
|
684
|
+
pointing to it, then it will not be in the resulting dictionary.
|
|
685
|
+
"""
|
|
686
|
+
to_side_link: dict[WrappedRecordModel, WrappedType] = RecordHandler\
|
|
687
|
+
.map_to_reverse_side_link(models, field_name, side_link_type)
|
|
688
|
+
by_side_link: dict[WrappedType, WrappedRecordModel] = {}
|
|
689
|
+
for record, side_link in to_side_link.items():
|
|
690
|
+
if side_link is None:
|
|
691
|
+
continue
|
|
692
|
+
if side_link in by_side_link:
|
|
693
|
+
raise SapioException(f"Side link {side_link.data_type_name} {side_link.record_id} encountered more "
|
|
694
|
+
f"than once in models list.")
|
|
695
|
+
by_side_link[side_link] = record
|
|
696
|
+
return by_side_link
|
|
697
|
+
|
|
483
698
|
@staticmethod
|
|
484
699
|
def map_by_id(models: Iterable[SapioRecord]) -> dict[int, SapioRecord]:
|
|
485
700
|
"""
|
|
@@ -690,7 +905,7 @@ class RecordHandler:
|
|
|
690
905
|
relationship path must already be loaded.
|
|
691
906
|
|
|
692
907
|
The path is "flattened" by only following the first record at each step. Useful for traversing 1-to-Many-to-1
|
|
693
|
-
relationships (e.g. a sample
|
|
908
|
+
relationships (e.g. a sample which is aliquoted to a number of samples, then those aliquots are pooled back
|
|
694
909
|
together into a single sample).
|
|
695
910
|
|
|
696
911
|
Currently, the relationship path may only contain parent/child nodes.
|
|
@@ -719,10 +934,28 @@ class RecordHandler:
|
|
|
719
934
|
ret_dict.update({model: self.inst_man.wrap(current[0], wrapper_type) if current else None})
|
|
720
935
|
return ret_dict
|
|
721
936
|
|
|
722
|
-
def
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
937
|
+
def __find_model(self, wrapper_type: type[WrappedType], primary_identifier: str, id_value: Any,
|
|
938
|
+
secondary_identifiers: FieldMap | None = None) -> WrappedType | None:
|
|
939
|
+
"""
|
|
940
|
+
Find a record from the system that matches the given field values. The primary identifier and value is used
|
|
941
|
+
to query for the record, then the secondary identifiers may be optionally provided to further filter the
|
|
942
|
+
returned results. If no record is found with these filters, returns None.
|
|
943
|
+
"""
|
|
944
|
+
# Query for all records that match the primary identifier.
|
|
945
|
+
results: list[WrappedType] = self.query_models(wrapper_type, primary_identifier, [id_value])
|
|
946
|
+
|
|
947
|
+
# Find the one record, if any, that matches the secondary identifiers.
|
|
948
|
+
unique_record: WrappedType | None = None
|
|
949
|
+
for result in results:
|
|
950
|
+
matches_all: bool = True
|
|
951
|
+
for field, value in secondary_identifiers.items():
|
|
952
|
+
if result.get_field_value(field) != value:
|
|
953
|
+
matches_all = False
|
|
954
|
+
break
|
|
955
|
+
if matches_all:
|
|
956
|
+
# If a previous record in the results already matched all identifiers, then throw an exception.
|
|
957
|
+
if unique_record is not None:
|
|
958
|
+
raise SapioException(f"More than one record of type {wrapper_type.get_wrapper_data_type_name()} "
|
|
959
|
+
f"encountered in system that matches all provided identifiers.")
|
|
960
|
+
unique_record = result
|
|
961
|
+
return unique_record
|
|
@@ -47,6 +47,7 @@ class CommonsWebhookHandler(AbstractWebhookHandler):
|
|
|
47
47
|
# Wrap the execution of each webhook in a try/catch. If an exception occurs, handle any special sapiopycommons
|
|
48
48
|
# exceptions. Otherwise, return a generic message stating that an error occurred.
|
|
49
49
|
try:
|
|
50
|
+
self.initialize(context)
|
|
50
51
|
return self.execute(context)
|
|
51
52
|
except SapioUserErrorException as e:
|
|
52
53
|
return self.handle_user_error_exception(e)
|
|
@@ -57,6 +58,13 @@ class CommonsWebhookHandler(AbstractWebhookHandler):
|
|
|
57
58
|
except Exception as e:
|
|
58
59
|
return self.handle_unexpected_exception(e)
|
|
59
60
|
|
|
61
|
+
def initialize(self, context: SapioWebhookContext) -> None:
|
|
62
|
+
"""
|
|
63
|
+
A function that can be optionally overridden by your webhooks to initialize additional instance variables,
|
|
64
|
+
or set up whatever else you wish to set up before the execute function is ran. Default behavior does nothing.
|
|
65
|
+
"""
|
|
66
|
+
pass
|
|
67
|
+
|
|
60
68
|
@abstractmethod
|
|
61
69
|
def execute(self, context: SapioWebhookContext) -> SapioWebhookResult:
|
|
62
70
|
"""
|
|
@@ -91,7 +99,8 @@ class CommonsWebhookHandler(AbstractWebhookHandler):
|
|
|
91
99
|
if result is not None:
|
|
92
100
|
return result
|
|
93
101
|
self.log_error(traceback.format_exc())
|
|
94
|
-
|
|
102
|
+
if self.can_send_client_callback():
|
|
103
|
+
DataMgmtServer.get_client_callback(self.context.user).display_error(e.args[0])
|
|
95
104
|
return SapioWebhookResult(False)
|
|
96
105
|
|
|
97
106
|
def handle_unexpected_exception(self, e: Exception) -> SapioWebhookResult:
|
|
@@ -237,3 +246,51 @@ class CommonsWebhookHandler(AbstractWebhookHandler):
|
|
|
237
246
|
:return: True if this endpoint was invoked as a scheduled action.
|
|
238
247
|
"""
|
|
239
248
|
return self.context.end_point_type == WebhookEndpointType.SCHEDULEDPLUGIN
|
|
249
|
+
|
|
250
|
+
def is_action_button_field(self) -> bool:
|
|
251
|
+
"""
|
|
252
|
+
:return: True if this endpoint was invoked as an action button field.
|
|
253
|
+
"""
|
|
254
|
+
return self.context.end_point_type == WebhookEndpointType.ACTIONDATAFIELD
|
|
255
|
+
|
|
256
|
+
def is_action_text_field(self) -> bool:
|
|
257
|
+
"""
|
|
258
|
+
:return: True if this endpoint was invoked as an action text field.
|
|
259
|
+
"""
|
|
260
|
+
return self.context.end_point_type == WebhookEndpointType.ACTION_TEXT_FIELD
|
|
261
|
+
|
|
262
|
+
def is_custom(self) -> bool:
|
|
263
|
+
"""
|
|
264
|
+
:return: True if this endpoint was invoked from a custom point, such as a custom queue.
|
|
265
|
+
"""
|
|
266
|
+
return self.context.end_point_type == WebhookEndpointType.CUSTOM
|
|
267
|
+
|
|
268
|
+
def is_calendar_event_click_handler(self) -> bool:
|
|
269
|
+
"""
|
|
270
|
+
:return: True if this endpoint was invoked from a calendar event click handler.
|
|
271
|
+
"""
|
|
272
|
+
return self.context.end_point_type == WebhookEndpointType.CALENDAR_EVENT_CLICK_HANDLER
|
|
273
|
+
|
|
274
|
+
def is_eln_menu_grabber(self) -> bool:
|
|
275
|
+
"""
|
|
276
|
+
:return: True if this endpoint was invoked as a notebook entry grabber.
|
|
277
|
+
"""
|
|
278
|
+
return self.context.end_point_type == WebhookEndpointType.NOTEBOOKEXPERIMENTGRABBER
|
|
279
|
+
|
|
280
|
+
def is_conversation_bot(self) -> bool:
|
|
281
|
+
"""
|
|
282
|
+
:return: True if this endpoint was invoked as from a conversation bot.
|
|
283
|
+
"""
|
|
284
|
+
return self.context.end_point_type == WebhookEndpointType.CONVERSATION_BOT
|
|
285
|
+
|
|
286
|
+
def is_multi_data_type_table_toolbar(self) -> bool:
|
|
287
|
+
"""
|
|
288
|
+
:return: True if this endpoint was invoked as a multi data type table toolbar button.
|
|
289
|
+
"""
|
|
290
|
+
return self.context.end_point_type == WebhookEndpointType.REPORTTOOLBAR
|
|
291
|
+
|
|
292
|
+
def can_send_client_callback(self) -> bool:
|
|
293
|
+
"""
|
|
294
|
+
:return: Whether client callbacks and directives can be sent from this webhook's endpoint type.
|
|
295
|
+
"""
|
|
296
|
+
return self.context.is_client_callback_available
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: sapiopycommons
|
|
3
|
-
Version: 2024.8.
|
|
3
|
+
Version: 2024.8.28a314
|
|
4
4
|
Summary: Official Sapio Python API Utilities Package
|
|
5
5
|
Project-URL: Homepage, https://github.com/sapiosciences
|
|
6
6
|
Author-email: Jonathan Steck <jsteck@sapiosciences.com>, Yechen Qiao <yqiao@sapiosciences.com>
|
|
@@ -17,7 +17,8 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
17
17
|
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
18
18
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
19
|
Requires-Python: >=3.10
|
|
20
|
-
Requires-Dist:
|
|
20
|
+
Requires-Dist: databind>=4.5
|
|
21
|
+
Requires-Dist: sapiopylib>=2024.5.24.210
|
|
21
22
|
Description-Content-Type: text/markdown
|
|
22
23
|
|
|
23
24
|
|
|
@@ -50,6 +51,7 @@ This license does not provide any rights to use any other copyrighted artifacts
|
|
|
50
51
|
## Dependencies
|
|
51
52
|
The following dependencies are required for this package:
|
|
52
53
|
- [sapiopylib - The official Sapio Informatics Platform Python API package.](https://pypi.org/project/sapiopylib/)
|
|
54
|
+
- [databind - Databind is a library inspired by jackson-databind to de-/serialize Python dataclasses.](https://pypi.org/project/databind/)
|
|
53
55
|
|
|
54
56
|
## Getting Help
|
|
55
57
|
If you have a support contract with Sapio Sciences, please use our [technical support channels](https://sapio-sciences.atlassian.net/servicedesk/customer/portals).
|
|
@@ -1,38 +1,43 @@
|
|
|
1
1
|
sapiopycommons/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
2
|
sapiopycommons/callbacks/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
-
sapiopycommons/callbacks/callback_util.py,sha256=
|
|
4
|
-
sapiopycommons/chem/IndigoMolecules.py,sha256=
|
|
5
|
-
sapiopycommons/chem/Molecules.py,sha256=
|
|
3
|
+
sapiopycommons/callbacks/callback_util.py,sha256=caeIWCHvK33jDs3TRskpJv0kDe7W8NPK4MyJPjgztwo,58012
|
|
4
|
+
sapiopycommons/chem/IndigoMolecules.py,sha256=QqFDi9CKERj6sn_ZwVcS2xZq4imlkaTeCrpq1iNcEJA,1992
|
|
5
|
+
sapiopycommons/chem/Molecules.py,sha256=t80IsQBPJ9mwE8ZxnWomAGrZDhdsOuPvLaTPb_N6jGU,8639
|
|
6
6
|
sapiopycommons/chem/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
7
|
sapiopycommons/datatype/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
8
|
sapiopycommons/datatype/attachment_util.py,sha256=YlnMprj5IGBbAZDLG2khS1P7JIYTw_NYfpJAfRZfP3M,3219
|
|
9
9
|
sapiopycommons/eln/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
10
|
sapiopycommons/eln/experiment_handler.py,sha256=v1pG4qtZb8OSNWfKtFo6NjnEkReqnu5R9i_hqWh_xxg,57198
|
|
11
|
+
sapiopycommons/eln/experiment_report_util.py,sha256=FTLw-6SLAMeoWTOO-qhGROE9g54pZdyoQJIhiIzlwGw,7848
|
|
11
12
|
sapiopycommons/eln/plate_designer.py,sha256=FYJfhhNq8hdfuXgDYOYHy6g0m2zNwQXZWF_MTPzElDg,7184
|
|
12
13
|
sapiopycommons/files/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
14
|
sapiopycommons/files/complex_data_loader.py,sha256=XSJOl676mIklJo78v07-70u1b015a5DI4sqZPI3C-Tw,1475
|
|
14
|
-
sapiopycommons/files/file_bridge.py,sha256=
|
|
15
|
+
sapiopycommons/files/file_bridge.py,sha256=GI3-gWFzcL0q0c8jKOxTevbzJqtUpiElmkXfTnMsaOo,6224
|
|
16
|
+
sapiopycommons/files/file_bridge_handler.py,sha256=MU2wZR4VY606yx6Bnv8-LzG3mGCeuXeRBn914WNRFCo,13601
|
|
15
17
|
sapiopycommons/files/file_data_handler.py,sha256=3-guAdhJdeJWAFq1a27ijspkO7uMMZ6CapMCD_6o4jA,36746
|
|
16
|
-
sapiopycommons/files/file_util.py,sha256=
|
|
17
|
-
sapiopycommons/files/file_validator.py,sha256=
|
|
18
|
+
sapiopycommons/files/file_util.py,sha256=44mzhn3M_QltoncBB-ooX7_yO6u5k-XU_bzUXHGxUiw,26299
|
|
19
|
+
sapiopycommons/files/file_validator.py,sha256=BhXB2XnoNEzdBXuwul1s2RNoj-3ZoiMmephUCU_0o3Y,28113
|
|
18
20
|
sapiopycommons/files/file_writer.py,sha256=5u_iZXTQvuUU7ceHZr8Q001_tvgJhOqBwAnB_pxcAbQ,16027
|
|
19
21
|
sapiopycommons/general/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
22
|
+
sapiopycommons/general/accession_service.py,sha256=HYgyOsH_UaoRnoury-c2yTW8SeG4OtjLemdpCzoV4R8,13484
|
|
20
23
|
sapiopycommons/general/aliases.py,sha256=i6af5o2oVFGNcyk7GkvTWXQs0H9xTbFKc_GIah8NKVU,3594
|
|
21
|
-
sapiopycommons/general/custom_report_util.py,sha256=
|
|
24
|
+
sapiopycommons/general/custom_report_util.py,sha256=cLgIR5Fn3M9uyAtgfTYRv3JRk2SKNevnsb_R5zidSYs,15557
|
|
22
25
|
sapiopycommons/general/exceptions.py,sha256=DOlLKnpCatxQF-lVCToa8ryJgusWLvip6N_1ALN00QE,1679
|
|
23
26
|
sapiopycommons/general/popup_util.py,sha256=-mN5IgYPrLrOEHJ4CHPi2rec4_WAN6X0yMxHwD5h3Bs,30126
|
|
24
27
|
sapiopycommons/general/storage_util.py,sha256=ovmK_jN7v09BoX07XxwShpBUC5WYQOM7dbKV_VeLXJU,8892
|
|
25
28
|
sapiopycommons/general/time_util.py,sha256=jiJUh7jc1ZRCOem880S3HaLPZ4RboBtSl4_U9sqAQuM,7290
|
|
29
|
+
sapiopycommons/multimodal/multimodal.py,sha256=A1QsC8QTPmgZyPr7KtMbPRedn2Ie4WIErodUvQ9otgU,6724
|
|
30
|
+
sapiopycommons/multimodal/multimodal_data.py,sha256=zqgYHO-ULaPKV0POFWZVY9N-Sfm1RQWwdsfwFxe5DjI,15038
|
|
26
31
|
sapiopycommons/processtracking/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
27
32
|
sapiopycommons/processtracking/endpoints.py,sha256=g5h_uCVByqacYm9zWAz8TyAdRsGfaO2o0b5RSJdOaSA,10926
|
|
28
33
|
sapiopycommons/recordmodel/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
29
|
-
sapiopycommons/recordmodel/record_handler.py,sha256=
|
|
34
|
+
sapiopycommons/recordmodel/record_handler.py,sha256=AyK1H3x-g1eu1Mt9XD1h57yRrZp_TJjZlEaQ2kPP4Dc,54432
|
|
30
35
|
sapiopycommons/rules/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
31
36
|
sapiopycommons/rules/eln_rule_handler.py,sha256=qfkBZtck0KK1i9s9Xe2UZqkzQOgPCzDxRkhxE8Si1xk,10671
|
|
32
37
|
sapiopycommons/rules/on_save_rule_handler.py,sha256=JY9F30IcHwFVdgPAMQtTYuRastV1jeezhVktyrzNASU,10763
|
|
33
38
|
sapiopycommons/webhook/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
34
|
-
sapiopycommons/webhook/webhook_handlers.py,sha256=
|
|
35
|
-
sapiopycommons-2024.8.
|
|
36
|
-
sapiopycommons-2024.8.
|
|
37
|
-
sapiopycommons-2024.8.
|
|
38
|
-
sapiopycommons-2024.8.
|
|
39
|
+
sapiopycommons/webhook/webhook_handlers.py,sha256=ibpBY3Sk3Eij919bIdW0awzlogYoQSWYDDOg--NwsQE,13431
|
|
40
|
+
sapiopycommons-2024.8.28a314.dist-info/METADATA,sha256=pZYfrhMqAs3EgS-_J1vPfJH9t-ZTyeKp8JhmHKgHqOI,3176
|
|
41
|
+
sapiopycommons-2024.8.28a314.dist-info/WHEEL,sha256=1yFddiXMmvYK7QYTqtRNtX66WJ0Mz8PYEiEUoOUUxRY,87
|
|
42
|
+
sapiopycommons-2024.8.28a314.dist-info/licenses/LICENSE,sha256=HyVuytGSiAUQ6ErWBHTqt1iSGHhLmlC8fO7jTCuR8dU,16725
|
|
43
|
+
sapiopycommons-2024.8.28a314.dist-info/RECORD,,
|
|
File without changes
|
{sapiopycommons-2024.8.28a313.dist-info → sapiopycommons-2024.8.28a314.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|