sapiopycommons 2025.2.25a449__py3-none-any.whl → 2025.3.6a451__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/ai/tool_of_tools.py +130 -105
- sapiopycommons/callbacks/callback_util.py +3 -3
- sapiopycommons/customreport/auto_pagers.py +25 -17
- sapiopycommons/files/file_writer.py +1 -1
- sapiopycommons/general/aliases.py +12 -5
- sapiopycommons/general/html_formatter.py +456 -0
- sapiopycommons/general/popup_util.py +2 -2
- sapiopycommons/general/sapio_links.py +12 -4
- sapiopycommons/recordmodel/record_handler.py +137 -101
- {sapiopycommons-2025.2.25a449.dist-info → sapiopycommons-2025.3.6a451.dist-info}/METADATA +1 -1
- {sapiopycommons-2025.2.25a449.dist-info → sapiopycommons-2025.3.6a451.dist-info}/RECORD +13 -12
- {sapiopycommons-2025.2.25a449.dist-info → sapiopycommons-2025.3.6a451.dist-info}/WHEEL +0 -0
- {sapiopycommons-2025.2.25a449.dist-info → sapiopycommons-2025.3.6a451.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
import base64
|
|
2
2
|
import io
|
|
3
3
|
import math
|
|
4
|
-
import re
|
|
5
4
|
from typing import Final, Mapping, Any
|
|
6
5
|
|
|
7
6
|
import requests
|
|
8
7
|
from pandas import DataFrame
|
|
9
8
|
from requests import Response
|
|
9
|
+
from sapiopylib.rest.DataMgmtService import DataMgmtServer
|
|
10
10
|
from sapiopylib.rest.DataRecordManagerService import DataRecordManager
|
|
11
11
|
from sapiopylib.rest.DataTypeService import DataTypeManager
|
|
12
12
|
from sapiopylib.rest.ELNService import ElnManager
|
|
13
13
|
from sapiopylib.rest.User import SapioUser
|
|
14
14
|
from sapiopylib.rest.pojo.DataRecord import DataRecord
|
|
15
15
|
from sapiopylib.rest.pojo.Sort import SortDirection
|
|
16
|
-
from sapiopylib.rest.pojo.chartdata.DashboardDefinition import GaugeChartDefinition
|
|
17
|
-
from sapiopylib.rest.pojo.chartdata.DashboardEnums import ChartGroupingType, ChartOperationType
|
|
16
|
+
from sapiopylib.rest.pojo.chartdata.DashboardDefinition import GaugeChartDefinition, DashboardDefinition
|
|
17
|
+
from sapiopylib.rest.pojo.chartdata.DashboardEnums import ChartGroupingType, ChartOperationType, DashboardScope
|
|
18
18
|
from sapiopylib.rest.pojo.chartdata.DashboardSeries import GaugeChartSeries
|
|
19
19
|
from sapiopylib.rest.pojo.datatype.DataType import DataTypeDefinition
|
|
20
20
|
from sapiopylib.rest.pojo.datatype.DataTypeLayout import DataTypeLayout, TableLayout
|
|
@@ -24,8 +24,8 @@ from sapiopylib.rest.pojo.eln.ElnEntryPosition import ElnEntryPosition
|
|
|
24
24
|
from sapiopylib.rest.pojo.eln.ElnExperiment import ElnExperiment
|
|
25
25
|
from sapiopylib.rest.pojo.eln.ExperimentEntry import ExperimentEntry
|
|
26
26
|
from sapiopylib.rest.pojo.eln.ExperimentEntryCriteria import ElnEntryCriteria, ElnFormEntryUpdateCriteria, \
|
|
27
|
-
ElnDashboardEntryUpdateCriteria
|
|
28
|
-
from sapiopylib.rest.pojo.eln.SapioELNEnums import ElnEntryType, ElnBaseDataType
|
|
27
|
+
ElnDashboardEntryUpdateCriteria
|
|
28
|
+
from sapiopylib.rest.pojo.eln.SapioELNEnums import ElnEntryType, ElnBaseDataType, ExperimentEntryStatus
|
|
29
29
|
from sapiopylib.rest.pojo.eln.eln_headings import ElnExperimentTabAddCriteria, ElnExperimentTab
|
|
30
30
|
from sapiopylib.rest.pojo.eln.field_set import ElnFieldSetInfo
|
|
31
31
|
from sapiopylib.rest.utils.ProtocolUtils import ELNStepFactory
|
|
@@ -34,6 +34,7 @@ from sapiopylib.rest.utils.Protocols import ElnEntryStep, ElnExperimentProtocol
|
|
|
34
34
|
from sapiopycommons.callbacks.field_builder import FieldBuilder
|
|
35
35
|
from sapiopycommons.general.aliases import AliasUtil, SapioRecord
|
|
36
36
|
from sapiopycommons.general.exceptions import SapioException
|
|
37
|
+
from sapiopycommons.general.html_formatter import HtmlFormatter
|
|
37
38
|
from sapiopycommons.general.time_util import TimeUtil
|
|
38
39
|
from sapiopycommons.multimodal.multimodal import MultiModalManager
|
|
39
40
|
from sapiopycommons.multimodal.multimodal_data import ImageDataRequestPojo
|
|
@@ -101,88 +102,6 @@ def format_tot_headers(headers: Mapping[str, str]) -> dict[str, str]:
|
|
|
101
102
|
return {k.lower(): v for k, v in headers.items()}
|
|
102
103
|
|
|
103
104
|
|
|
104
|
-
class HtmlFormatter:
|
|
105
|
-
"""
|
|
106
|
-
A class for formatting text in HTML with tag classes supported by the client.
|
|
107
|
-
"""
|
|
108
|
-
TIMESTAMP_TEXT__CSS_CLASS_NAME: Final[str] = "timestamp-text"
|
|
109
|
-
HEADER_1_TEXT__CSS_CLASS_NAME: Final[str] = "header1-text"
|
|
110
|
-
HEADER_2_TEXT__CSS_CLASS_NAME: Final[str] = "header2-text"
|
|
111
|
-
HEADER_3_TEXT__CSS_CLASS_NAME: Final[str] = "header3-text"
|
|
112
|
-
BODY_TEXT__CSS_CLASS_NAME: Final[str] = "body-text"
|
|
113
|
-
CAPTION_TEXT__CSS_CLASS_NAME: Final[str] = "caption-text"
|
|
114
|
-
|
|
115
|
-
@staticmethod
|
|
116
|
-
def timestamp(text: str) -> str:
|
|
117
|
-
"""
|
|
118
|
-
Given a text string, return that same text string HTML formatted using the timestamp CSS class.
|
|
119
|
-
|
|
120
|
-
:param text: The text to format.
|
|
121
|
-
:return: The HTML formatted text.
|
|
122
|
-
"""
|
|
123
|
-
return f"<span class=\"{HtmlFormatter.TIMESTAMP_TEXT__CSS_CLASS_NAME}\">{text}</span>"
|
|
124
|
-
|
|
125
|
-
@staticmethod
|
|
126
|
-
def header_1(text: str) -> str:
|
|
127
|
-
"""
|
|
128
|
-
Given a text string, return that same text string HTML formatted using the header 1 CSS class.
|
|
129
|
-
|
|
130
|
-
:param text: The text to format.
|
|
131
|
-
:return: The HTML formatted text.
|
|
132
|
-
"""
|
|
133
|
-
return f"<span class=\"{HtmlFormatter.HEADER_1_TEXT__CSS_CLASS_NAME}\">{text}</span>"
|
|
134
|
-
|
|
135
|
-
@staticmethod
|
|
136
|
-
def header_2(text: str) -> str:
|
|
137
|
-
"""
|
|
138
|
-
Given a text string, return that same text string HTML formatted using the header 2 CSS class.
|
|
139
|
-
|
|
140
|
-
:param text: The text to format.
|
|
141
|
-
:return: The HTML formatted text.
|
|
142
|
-
"""
|
|
143
|
-
return f"<span class=\"{HtmlFormatter.HEADER_2_TEXT__CSS_CLASS_NAME}\">{text}</span>"
|
|
144
|
-
|
|
145
|
-
@staticmethod
|
|
146
|
-
def header_3(text: str) -> str:
|
|
147
|
-
"""
|
|
148
|
-
Given a text string, return that same text string HTML formatted using the header 3 CSS class.
|
|
149
|
-
|
|
150
|
-
:param text: The text to format.
|
|
151
|
-
:return: The HTML formatted text.
|
|
152
|
-
"""
|
|
153
|
-
return f"<span class=\"{HtmlFormatter.HEADER_3_TEXT__CSS_CLASS_NAME}\">{text}</span>"
|
|
154
|
-
|
|
155
|
-
@staticmethod
|
|
156
|
-
def body(text: str) -> str:
|
|
157
|
-
"""
|
|
158
|
-
Given a text string, return that same text string HTML formatted using the body CSS class.
|
|
159
|
-
|
|
160
|
-
:param text: The text to format.
|
|
161
|
-
:return: The HTML formatted text.
|
|
162
|
-
"""
|
|
163
|
-
return f"<span class=\"{HtmlFormatter.BODY_TEXT__CSS_CLASS_NAME}\">{text}</span>"
|
|
164
|
-
|
|
165
|
-
@staticmethod
|
|
166
|
-
def caption(text: str) -> str:
|
|
167
|
-
"""
|
|
168
|
-
Given a text string, return that same text string HTML formatted using the caption CSS class.
|
|
169
|
-
|
|
170
|
-
:param text: The text to format.
|
|
171
|
-
:return: The HTML formatted text.
|
|
172
|
-
"""
|
|
173
|
-
return f"<span class=\"{HtmlFormatter.CAPTION_TEXT__CSS_CLASS_NAME}\">{text}</span>"
|
|
174
|
-
|
|
175
|
-
@staticmethod
|
|
176
|
-
def replace_newlines(text: str) -> str:
|
|
177
|
-
"""
|
|
178
|
-
Given a text string, return that same text string HTML formatted with newlines replaced by HTML line breaks.
|
|
179
|
-
|
|
180
|
-
:param text: The text to format.
|
|
181
|
-
:return: The HTML formatted text.
|
|
182
|
-
"""
|
|
183
|
-
return re.sub("\r?\n", "<br>", text)
|
|
184
|
-
|
|
185
|
-
|
|
186
105
|
class AiHelper:
|
|
187
106
|
"""
|
|
188
107
|
A class with helper methods for the AI to make use of when creating/updating experiment tabs and entries.
|
|
@@ -707,16 +626,18 @@ class ToolOfToolsHelper:
|
|
|
707
626
|
x.field_set_name == "Tool of Tools Progress"]
|
|
708
627
|
if not progress_field_set:
|
|
709
628
|
raise SapioException("Unable to locate the field set for the Tool of Tools progress.")
|
|
710
|
-
progress_entry_crit =
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
629
|
+
progress_entry_crit = _ElnEntryCriteria(ElnEntryType.Form, f"{tab_name} Progress",
|
|
630
|
+
ElnBaseDataType.EXPERIMENT_DETAIL.data_type_name, 1,
|
|
631
|
+
notebook_experiment_tab_id=self.tab.tab_id,
|
|
632
|
+
enb_field_set_id=progress_field_set[0].field_set_id,
|
|
633
|
+
is_hidden=True)
|
|
714
634
|
progress_entry = ElnEntryStep(self.helper.protocol,
|
|
715
635
|
self.eln_man.add_experiment_entry(self.exp_id, progress_entry_crit))
|
|
716
636
|
self.progress_entry = progress_entry
|
|
717
637
|
self.progress_record = progress_entry.get_records()[0]
|
|
718
638
|
|
|
719
639
|
# Hide the progress entry.
|
|
640
|
+
# TODO: Remove once we get this working on entry creation.
|
|
720
641
|
form_update_crit = ElnFormEntryUpdateCriteria()
|
|
721
642
|
form_update_crit.is_hidden = True
|
|
722
643
|
self.eln_man.update_experiment_entry(self.exp_id, self.progress_entry.get_id(), form_update_crit)
|
|
@@ -725,26 +646,22 @@ class ToolOfToolsHelper:
|
|
|
725
646
|
# tool started and format the description so that the text isn't too small to read.
|
|
726
647
|
# TODO: Get the UTC offset in seconds from the header once that's being sent.
|
|
727
648
|
now: str = TimeUtil.now_in_format("%Y-%m-%d %H:%M:%S UTC", "UTC")
|
|
728
|
-
|
|
649
|
+
description: str = f"<p>{HtmlFormatter.timestamp(now)}<br>{HtmlFormatter.body(self.description)}</p>"
|
|
650
|
+
text_entry: ElnEntryStep = _ELNStepFactory.create_text_entry(self.helper.protocol, description,
|
|
651
|
+
column_order=0, column_span=2)
|
|
729
652
|
self.description_entry = text_entry
|
|
730
653
|
self.description_record = text_entry.get_records()[0]
|
|
731
654
|
|
|
732
|
-
# Shrink the text entry by one column.
|
|
733
|
-
text_update_crit = ElnTextEntryUpdateCriteria()
|
|
734
|
-
text_update_crit.column_order = 0
|
|
735
|
-
text_update_crit.column_span = 2
|
|
736
|
-
self.eln_man.update_experiment_entry(self.exp_id, self.description_entry.get_id(), text_update_crit)
|
|
737
|
-
|
|
738
655
|
# Create a gauge entry to display the progress.
|
|
739
|
-
gauge_entry: ElnEntryStep =
|
|
740
|
-
|
|
656
|
+
gauge_entry: ElnEntryStep = _ELNStepFactory._create_gauge_chart(self.helper.protocol, progress_entry,
|
|
657
|
+
f"{self.name} Progress", "Progress", "StatusMsg",
|
|
658
|
+
column_order=2, column_span=2, entry_height=250)
|
|
741
659
|
self.progress_gauge_entry = gauge_entry
|
|
742
660
|
|
|
743
661
|
# Make sure the gauge entry isn't too big and stick it to the right of the text entry.
|
|
662
|
+
# TODO: Remove once we get this working on entry creation.
|
|
744
663
|
dash_update_crit = ElnDashboardEntryUpdateCriteria()
|
|
745
664
|
dash_update_crit.entry_height = 250
|
|
746
|
-
dash_update_crit.column_order = 2
|
|
747
|
-
dash_update_crit.column_span = 2
|
|
748
665
|
self.eln_man.update_experiment_entry(self.exp_id, self.progress_gauge_entry.get_id(), dash_update_crit)
|
|
749
666
|
|
|
750
667
|
# Create a results entry if this tool produces result records.
|
|
@@ -839,10 +756,37 @@ class ToolOfToolsHelper:
|
|
|
839
756
|
|
|
840
757
|
return self.helper.create_attachment_entry_from_file(self.tab, entry_name, file_path)
|
|
841
758
|
|
|
759
|
+
|
|
760
|
+
class _ELNStepFactory:
|
|
761
|
+
"""
|
|
762
|
+
Factory that provides simple functions to create a new ELN step under an ELN protocol.
|
|
763
|
+
"""
|
|
764
|
+
@staticmethod
|
|
765
|
+
def create_text_entry(protocol: ElnExperimentProtocol, text_data: str,
|
|
766
|
+
position: ElnEntryPosition | None = None, **kwargs) -> ElnEntryStep:
|
|
767
|
+
"""
|
|
768
|
+
Create a text entry at the end of the protocol, with a initial text specified in the text entry.
|
|
769
|
+
:param protocol: The protocol to create a new step for.
|
|
770
|
+
:param text_data: Must be non-blank. This is what will be displayed. Some HTML format tags can be inserted.
|
|
771
|
+
:param position: The position of the new step. If not specified, the new step will be added at the end.
|
|
772
|
+
:return: The new text entry step.
|
|
773
|
+
"""
|
|
774
|
+
eln_manager, new_entry = _ELNStepFactory._get_entry_creation_criteria(ElnBaseDataType.TEXT_ENTRY_DETAIL.data_type_name,
|
|
775
|
+
protocol, 'Text Entry', ElnEntryType.Text,
|
|
776
|
+
position, **kwargs)
|
|
777
|
+
record = eln_manager.get_data_records_for_entry(protocol.eln_experiment.notebook_experiment_id,
|
|
778
|
+
new_entry.entry_id).result_list[0]
|
|
779
|
+
record.set_field_value(ElnBaseDataType.get_text_entry_data_field_name(), text_data)
|
|
780
|
+
DataMgmtServer.get_data_record_manager(protocol.user).commit_data_records([record])
|
|
781
|
+
ret = ElnEntryStep(protocol, new_entry)
|
|
782
|
+
protocol.invalidate()
|
|
783
|
+
return ret
|
|
784
|
+
|
|
842
785
|
# TODO: Remove this once pylib's gauge chart definition is up to date.
|
|
843
786
|
@staticmethod
|
|
844
787
|
def _create_gauge_chart(protocol: ElnExperimentProtocol, data_source_step: ElnEntryStep, step_name: str,
|
|
845
|
-
field_name: str, status_field: str, group_by_field_name: str = "DataRecordName"
|
|
788
|
+
field_name: str, status_field: str, group_by_field_name: str = "DataRecordName",
|
|
789
|
+
**kwargs) \
|
|
846
790
|
-> ElnEntryStep:
|
|
847
791
|
"""
|
|
848
792
|
Create a gauge chart step in the experiment protocol.
|
|
@@ -861,11 +805,55 @@ class ToolOfToolsHelper:
|
|
|
861
805
|
chart.grouping_type = ChartGroupingType.GROUP_BY_FIELD
|
|
862
806
|
chart.grouping_type_data_type_name = data_type_name
|
|
863
807
|
chart.grouping_type_data_field_name = group_by_field_name
|
|
864
|
-
dashboard, step =
|
|
865
|
-
|
|
808
|
+
dashboard, step = _ELNStepFactory._create_dashboard_step_from_chart(chart, data_source_step, protocol, step_name,
|
|
809
|
+
None, **kwargs)
|
|
866
810
|
protocol.invalidate()
|
|
867
811
|
return step
|
|
868
812
|
|
|
813
|
+
@staticmethod
|
|
814
|
+
def _create_dashboard_step_from_chart(chart: GaugeChartDefinition, data_source_step: ElnEntryStep,
|
|
815
|
+
protocol: ElnExperimentProtocol, step_name: str,
|
|
816
|
+
position: ElnEntryPosition | None = None, **kwargs) -> \
|
|
817
|
+
tuple[DashboardDefinition, ElnEntryStep]:
|
|
818
|
+
dashboard: DashboardDefinition = DashboardDefinition()
|
|
819
|
+
dashboard.chart_definition_list = [chart]
|
|
820
|
+
dashboard.dashboard_scope = DashboardScope.PRIVATE_ELN
|
|
821
|
+
dashboard = DataMgmtServer.get_dashboard_manager(protocol.user).store_dashboard_definition(dashboard)
|
|
822
|
+
eln_manager, new_entry = _ELNStepFactory._get_entry_creation_criteria("", protocol, step_name,
|
|
823
|
+
ElnEntryType.Dashboard, position,
|
|
824
|
+
**kwargs)
|
|
825
|
+
# noinspection PyTypeChecker
|
|
826
|
+
update_criteria = ElnDashboardEntryUpdateCriteria()
|
|
827
|
+
update_criteria.dashboard_guid = dashboard.dashboard_guid
|
|
828
|
+
update_criteria.data_source_entry_id = data_source_step.get_id()
|
|
829
|
+
update_criteria.entry_height = 500
|
|
830
|
+
eln_manager.update_experiment_entry(protocol.eln_experiment.notebook_experiment_id, new_entry.entry_id,
|
|
831
|
+
update_criteria)
|
|
832
|
+
step = ElnEntryStep(protocol, new_entry)
|
|
833
|
+
return dashboard, step
|
|
834
|
+
|
|
835
|
+
@staticmethod
|
|
836
|
+
def _get_entry_creation_criteria(data_type_name: str | None, protocol: ElnExperimentProtocol,
|
|
837
|
+
step_name: str, entry_type: ElnEntryType, position: ElnEntryPosition | None = None,
|
|
838
|
+
**kwargs):
|
|
839
|
+
tab_id: int | None = None
|
|
840
|
+
order: int | None = None
|
|
841
|
+
if position:
|
|
842
|
+
tab_id = position.tab_id
|
|
843
|
+
order = position.order
|
|
844
|
+
# noinspection PyTypeChecker
|
|
845
|
+
last_step: ElnEntryStep = protocol.get_sorted_step_list()[-1]
|
|
846
|
+
if tab_id is None:
|
|
847
|
+
tab_id = last_step.eln_entry.notebook_experiment_tab_id
|
|
848
|
+
if order is None:
|
|
849
|
+
order = last_step.eln_entry.order + 1
|
|
850
|
+
eln_manager = DataMgmtServer.get_eln_manager(protocol.user)
|
|
851
|
+
entry_criteria = _ElnEntryCriteria(entry_type, step_name, data_type_name, order,
|
|
852
|
+
notebook_experiment_tab_id=tab_id, **kwargs)
|
|
853
|
+
new_entry: ExperimentEntry = eln_manager.add_experiment_entry(protocol.eln_experiment.notebook_experiment_id,
|
|
854
|
+
entry_criteria)
|
|
855
|
+
return eln_manager, new_entry
|
|
856
|
+
|
|
869
857
|
|
|
870
858
|
# TODO: Using this to set the new status field setting.
|
|
871
859
|
class _GaugeChartDefinition(GaugeChartDefinition):
|
|
@@ -878,3 +866,40 @@ class _GaugeChartDefinition(GaugeChartDefinition):
|
|
|
878
866
|
"dataFieldName": self.status_field
|
|
879
867
|
}
|
|
880
868
|
return result
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
class _ElnEntryCriteria(ElnEntryCriteria):
|
|
872
|
+
is_hidden: bool | None
|
|
873
|
+
entry_height: int | None
|
|
874
|
+
description: str | None
|
|
875
|
+
is_initialization_required: bool | None
|
|
876
|
+
collapse_entry: bool | None
|
|
877
|
+
entry_status: ExperimentEntryStatus | None
|
|
878
|
+
template_item_fulfilled_timestamp: int | None
|
|
879
|
+
|
|
880
|
+
def __init__(self, entry_type: ElnEntryType, entry_name: str | None, data_type_name: str | None, order: int,
|
|
881
|
+
is_hidden: bool | None = None, entry_height: int | None = None, description: str | None = None,
|
|
882
|
+
is_initialization_required: bool | None = None, collapse_entry: bool | None = None,
|
|
883
|
+
entry_status: ExperimentEntryStatus | None = None, template_item_fulfilled_timestamp: int | None = None,
|
|
884
|
+
**kwargs):
|
|
885
|
+
super().__init__(entry_type, entry_name, data_type_name, order, **kwargs)
|
|
886
|
+
self.is_hidden = is_hidden
|
|
887
|
+
self.entry_height = entry_height
|
|
888
|
+
self.description = description
|
|
889
|
+
self.is_initialization_required = is_initialization_required
|
|
890
|
+
self.collapse_entry = collapse_entry
|
|
891
|
+
self.entry_status = entry_status
|
|
892
|
+
self.template_item_fulfilled_timestamp = template_item_fulfilled_timestamp
|
|
893
|
+
|
|
894
|
+
def to_json(self) -> dict[str, Any]:
|
|
895
|
+
ret: dict[str, Any] = super().to_json()
|
|
896
|
+
ret.update({
|
|
897
|
+
"hidden": self.is_hidden,
|
|
898
|
+
"entryHeight": self.entry_height,
|
|
899
|
+
"description": self.description,
|
|
900
|
+
"initializationRequired": self.is_initialization_required,
|
|
901
|
+
"collapsed": self.collapse_entry,
|
|
902
|
+
"entryStatus": self.entry_status,
|
|
903
|
+
"templateItemFulfilledTimestamp": self.template_item_fulfilled_timestamp
|
|
904
|
+
})
|
|
905
|
+
return ret
|
|
@@ -712,7 +712,7 @@ class CallbackUtil:
|
|
|
712
712
|
if not records:
|
|
713
713
|
raise SapioException("No records provided.")
|
|
714
714
|
data_type: str = AliasUtil.to_singular_data_type_name(records)
|
|
715
|
-
field_map_list: list[FieldMap] = AliasUtil.
|
|
715
|
+
field_map_list: list[FieldMap] = AliasUtil.to_field_map_list(records)
|
|
716
716
|
|
|
717
717
|
# Convert the group_by parameter to a field name.
|
|
718
718
|
if group_by is not None:
|
|
@@ -1192,7 +1192,7 @@ class CallbackUtil:
|
|
|
1192
1192
|
record: SapioRecord | FieldMap | None = row_records.get(field.data_type_name)
|
|
1193
1193
|
# This could be either a record, a field map, or null. Convert any records to field maps.
|
|
1194
1194
|
if not isinstance(record, dict) and record is not None:
|
|
1195
|
-
record: FieldMap | None = AliasUtil.
|
|
1195
|
+
record: FieldMap | None = AliasUtil.to_field_map(record)
|
|
1196
1196
|
|
|
1197
1197
|
# Find out if this field had its data type prepended to it. If this is the case, then we need to find
|
|
1198
1198
|
# the true data field name before retrieving the value from the field map.
|
|
@@ -1383,7 +1383,7 @@ class CallbackUtil:
|
|
|
1383
1383
|
if not records:
|
|
1384
1384
|
raise SapioException("No records provided.")
|
|
1385
1385
|
data_type: str = AliasUtil.to_singular_data_type_name(records)
|
|
1386
|
-
field_map_list: list[FieldMap] = AliasUtil.
|
|
1386
|
+
field_map_list: list[FieldMap] = AliasUtil.to_field_map_list(records, include_record_id=True)
|
|
1387
1387
|
|
|
1388
1388
|
# Key fields display their columns in order before all non-key fields.
|
|
1389
1389
|
# Unmark key fields so that the column order is respected exactly as the caller provides it.
|
|
@@ -8,6 +8,7 @@ from sapiopylib.rest.pojo.CustomReport import CustomReportCriteria, CustomReport
|
|
|
8
8
|
from sapiopylib.rest.pojo.datatype.FieldDefinition import FieldType
|
|
9
9
|
from sapiopylib.rest.utils.autopaging import SapioPyAutoPager, PagerResultCriteriaType, _default_report_page_size, \
|
|
10
10
|
_default_record_page_size
|
|
11
|
+
from sapiopylib.rest.utils.recordmodel.PyRecordModel import PyRecordModel
|
|
11
12
|
from sapiopylib.rest.utils.recordmodel.RecordModelWrapper import WrappedType
|
|
12
13
|
|
|
13
14
|
from sapiopycommons.general.aliases import FieldValue, UserIdentifier, AliasUtil, RecordModel
|
|
@@ -110,20 +111,20 @@ class QuickReportDictAutoPager(_DictReportPagerBase):
|
|
|
110
111
|
super().__init__(user, first_page_criteria)
|
|
111
112
|
|
|
112
113
|
|
|
113
|
-
class _RecordReportPagerBase(SapioPyAutoPager[CustomReportCriteria,
|
|
114
|
+
class _RecordReportPagerBase(SapioPyAutoPager[CustomReportCriteria, RecordModel], ABC):
|
|
114
115
|
"""
|
|
115
116
|
A base class for automatically paging through a report and returning the results as a list of records.
|
|
116
117
|
"""
|
|
117
118
|
_columns: list[ReportColumn]
|
|
118
|
-
|
|
119
|
+
_query_type: type[WrappedType] | str
|
|
119
120
|
_data_type: str
|
|
120
121
|
_rec_handler: RecordHandler
|
|
121
122
|
_report_man: CustomReportManager
|
|
122
123
|
|
|
123
|
-
def __init__(self, user: UserIdentifier, first_page_criteria: CustomReportCriteria, wrapper_type: type[WrappedType]):
|
|
124
|
+
def __init__(self, user: UserIdentifier, first_page_criteria: CustomReportCriteria, wrapper_type: type[WrappedType] | str):
|
|
124
125
|
self._columns = first_page_criteria.column_list
|
|
125
|
-
self.
|
|
126
|
-
self._data_type =
|
|
126
|
+
self._query_type = wrapper_type
|
|
127
|
+
self._data_type = AliasUtil.to_data_type_name(wrapper_type)
|
|
127
128
|
self._rec_handler = RecordHandler(user)
|
|
128
129
|
super().__init__(AliasUtil.to_sapio_user(user), first_page_criteria)
|
|
129
130
|
self._report_man = DataMgmtServer.get_custom_report_manager(self.user)
|
|
@@ -139,9 +140,9 @@ class _RecordReportPagerBase(SapioPyAutoPager[CustomReportCriteria, WrappedType]
|
|
|
139
140
|
def default_first_page_criteria(self) -> PagerResultCriteriaType:
|
|
140
141
|
raise ValueError("Cannot generate a default first page criteria for custom reports.")
|
|
141
142
|
|
|
142
|
-
def get_next_page_result(self) -> tuple[CustomReportCriteria | None, Queue[WrappedType]]:
|
|
143
|
+
def get_next_page_result(self) -> tuple[CustomReportCriteria | None, Queue[WrappedType] | Queue[PyRecordModel]]:
|
|
143
144
|
report: CustomReport = self._report_man.run_custom_report(self.next_page_criteria)
|
|
144
|
-
queue
|
|
145
|
+
queue = Queue()
|
|
145
146
|
id_index: int = -1
|
|
146
147
|
for i, column in enumerate(self._columns):
|
|
147
148
|
if column.data_type_name == self._data_type and column.data_field_name == "RecordId":
|
|
@@ -151,7 +152,7 @@ class _RecordReportPagerBase(SapioPyAutoPager[CustomReportCriteria, WrappedType]
|
|
|
151
152
|
raise SapioException(f"This report does not contain a Record ID column for the given record model type "
|
|
152
153
|
f"{self._data_type}.")
|
|
153
154
|
ids: list[int] = [row[id_index] for row in report.result_table]
|
|
154
|
-
for row in self._rec_handler.query_models_by_id(self.
|
|
155
|
+
for row in self._rec_handler.query_models_by_id(self._query_type, ids, page_size=report.page_size):
|
|
155
156
|
queue.put(row)
|
|
156
157
|
if report.has_next_page:
|
|
157
158
|
next_page_criteria = copy(self.next_page_criteria)
|
|
@@ -165,12 +166,15 @@ class CustomReportRecordAutoPager(_RecordReportPagerBase):
|
|
|
165
166
|
"""
|
|
166
167
|
A class that automatically pages through a custom report and returns the results as a list of records.
|
|
167
168
|
"""
|
|
168
|
-
def __init__(self, user: UserIdentifier, report_criteria: CustomReportCriteria,
|
|
169
|
-
|
|
169
|
+
def __init__(self, user: UserIdentifier, report_criteria: CustomReportCriteria,
|
|
170
|
+
wrapper_type: type[WrappedType] | str, page_number: int = 0,
|
|
171
|
+
page_size: int = _default_record_page_size):
|
|
170
172
|
"""
|
|
171
173
|
:param user: The current webhook context or a user object to send requests from.
|
|
172
174
|
:param report_criteria: The custom report criteria to run.
|
|
173
|
-
:param wrapper_type: The record model wrapper type
|
|
175
|
+
:param wrapper_type: The record model wrapper type or data type name of the records being searched for.
|
|
176
|
+
If a data type name was used instead of a model wrapper, then the returned records will be PyRecordModels
|
|
177
|
+
instead of WrappedRecordModels.
|
|
174
178
|
:param page_number: The page number to start on. The first page is page 0.
|
|
175
179
|
:param page_size: The number of results to return per page.
|
|
176
180
|
"""
|
|
@@ -188,12 +192,14 @@ class SystemReportRecordAutoPager(_RecordReportPagerBase):
|
|
|
188
192
|
System reports are also known as predefined searches in the system and must be defined in the data designer for
|
|
189
193
|
a specific data type. That is, saved searches created by users cannot be run using this function.
|
|
190
194
|
"""
|
|
191
|
-
def __init__(self, user: UserIdentifier, report_name: str, wrapper_type: type[WrappedType],
|
|
195
|
+
def __init__(self, user: UserIdentifier, report_name: str, wrapper_type: type[WrappedType] | str,
|
|
192
196
|
page_number: int = 0, page_size: int = _default_record_page_size):
|
|
193
197
|
"""
|
|
194
198
|
:param user: The current webhook context or a user object to send requests from.
|
|
195
199
|
:param report_name: The name of the system report to run.
|
|
196
|
-
:param wrapper_type: The record model wrapper type
|
|
200
|
+
:param wrapper_type: The record model wrapper type or data type name of the records being searched for.
|
|
201
|
+
If a data type name was used instead of a model wrapper, then the returned records will be PyRecordModels
|
|
202
|
+
instead of WrappedRecordModels.
|
|
197
203
|
:param page_number: The page number to start on. The first page is page 0.
|
|
198
204
|
:param page_size: The number of results to return per page.
|
|
199
205
|
"""
|
|
@@ -208,12 +214,14 @@ class QuickReportRecordAutoPager(_RecordReportPagerBase):
|
|
|
208
214
|
"""
|
|
209
215
|
A class that automatically pages through a quick report and returns the results as a list of records.
|
|
210
216
|
"""
|
|
211
|
-
def __init__(self, user: UserIdentifier, report_term: RawReportTerm, wrapper_type: type[WrappedType],
|
|
217
|
+
def __init__(self, user: UserIdentifier, report_term: RawReportTerm, wrapper_type: type[WrappedType] | str,
|
|
212
218
|
page_number: int = 0, page_size: int = _default_record_page_size):
|
|
213
219
|
"""
|
|
214
220
|
:param user: The current webhook context or a user object to send requests from.
|
|
215
221
|
:param report_term: The raw report term to use for the quick report.
|
|
216
|
-
:param wrapper_type: The record model wrapper type
|
|
222
|
+
:param wrapper_type: The record model wrapper type or data type name of the records being searched for.
|
|
223
|
+
If a data type name was used instead of a model wrapper, then the returned records will be PyRecordModels
|
|
224
|
+
instead of WrappedRecordModels.
|
|
217
225
|
:param page_number: The page number to start on. The first page is page 0.
|
|
218
226
|
:param page_size: The number of results to return per page.
|
|
219
227
|
"""
|
|
@@ -225,12 +233,12 @@ class QuickReportRecordAutoPager(_RecordReportPagerBase):
|
|
|
225
233
|
super().__init__(user, first_page_criteria, wrapper_type)
|
|
226
234
|
|
|
227
235
|
|
|
228
|
-
def _add_record_id_column(report: CustomReportCriteria, wrapper_type: type[WrappedType]) -> None:
|
|
236
|
+
def _add_record_id_column(report: CustomReportCriteria, wrapper_type: type[WrappedType] | str) -> None:
|
|
229
237
|
"""
|
|
230
238
|
Given a custom report criteria, ensure that the report contains a Record ID column for the given record model's
|
|
231
239
|
data type. Add one if it is missing.
|
|
232
240
|
"""
|
|
233
|
-
dt: str =
|
|
241
|
+
dt: str = AliasUtil.to_data_type_name(wrapper_type)
|
|
234
242
|
# Ensure that the root data type is the one we're looking for.
|
|
235
243
|
report.root_data_type = dt
|
|
236
244
|
# Enforce that the given custom report has a record ID column.
|
|
@@ -307,7 +307,7 @@ class FieldColumn(ColumnDef):
|
|
|
307
307
|
elif self.search_order == FieldSearchOrder.BUNDLE_ONLY:
|
|
308
308
|
return row.fields.get(self.field_name)
|
|
309
309
|
elif self.search_order == FieldSearchOrder.RECORD_FIRST:
|
|
310
|
-
fields: dict[str, Any] = AliasUtil.
|
|
310
|
+
fields: dict[str, Any] = AliasUtil.to_field_map(record) if record else {}
|
|
311
311
|
if self.field_name not in fields or (self.skip_none_values and fields.get(self.field_name) is None):
|
|
312
312
|
return row.fields.get(self.field_name)
|
|
313
313
|
return fields.get(self.field_name)
|
|
@@ -207,10 +207,12 @@ class AliasUtil:
|
|
|
207
207
|
f"field with the name \"{field}\",")
|
|
208
208
|
|
|
209
209
|
@staticmethod
|
|
210
|
-
def to_field_map(record: SapioRecord) -> FieldMap:
|
|
210
|
+
def to_field_map(record: SapioRecord, include_record_id: bool = False) -> FieldMap:
|
|
211
211
|
"""
|
|
212
|
-
Convert a given record value to a field map.
|
|
212
|
+
Convert a given record value to a field map.
|
|
213
213
|
|
|
214
|
+
:param record: A record which is a DataRecord, PyRecordModel, or WrappedRecordModel.
|
|
215
|
+
:param include_record_id: If true, include the record ID of the record in the field map using the RecordId key.
|
|
214
216
|
:return: The field map for the input record.
|
|
215
217
|
"""
|
|
216
218
|
if isinstance(record, DataRecord):
|
|
@@ -218,20 +220,25 @@ class AliasUtil:
|
|
|
218
220
|
fields: FieldMap = record.get_fields()
|
|
219
221
|
else:
|
|
220
222
|
fields: FieldMap = record.fields.copy_to_dict()
|
|
221
|
-
|
|
223
|
+
# PR-47457: Only include the record ID if the caller requests it, since including the record ID can break
|
|
224
|
+
# callbacks in certain circumstances if the record ID is negative.
|
|
225
|
+
if include_record_id:
|
|
226
|
+
fields["RecordId"] = AliasUtil.to_record_id(record)
|
|
222
227
|
return fields
|
|
223
228
|
|
|
224
229
|
@staticmethod
|
|
225
|
-
def
|
|
230
|
+
def to_field_map_list(records: Iterable[SapioRecord], include_record_id: bool = False) -> list[FieldMap]:
|
|
226
231
|
"""
|
|
227
232
|
Convert a list of variables that could either be DataRecords, PyRecordModels, or WrappedRecordModels
|
|
228
233
|
to a list of their field maps. This includes the given RecordId of the given records.
|
|
229
234
|
|
|
235
|
+
:param records: An iterable of records which are DataRecords, PyRecordModels, or WrappedRecordModels.
|
|
236
|
+
:param include_record_id: If true, include the record ID of the records in the field map using the RecordId key.
|
|
230
237
|
:return: A list of field maps for the input records.
|
|
231
238
|
"""
|
|
232
239
|
field_map_list: list[FieldMap] = []
|
|
233
240
|
for record in records:
|
|
234
|
-
field_map_list.append(AliasUtil.to_field_map(record))
|
|
241
|
+
field_map_list.append(AliasUtil.to_field_map(record, include_record_id))
|
|
235
242
|
return field_map_list
|
|
236
243
|
|
|
237
244
|
@staticmethod
|