sapiopycommons 2025.4.9a150__py3-none-any.whl → 2025.4.9a476__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of sapiopycommons might be problematic. Click here for more details.

Files changed (42) hide show
  1. sapiopycommons/callbacks/callback_util.py +1262 -392
  2. sapiopycommons/callbacks/field_builder.py +2 -0
  3. sapiopycommons/chem/Molecules.py +0 -2
  4. sapiopycommons/customreport/auto_pagers.py +281 -0
  5. sapiopycommons/customreport/term_builder.py +1 -1
  6. sapiopycommons/datatype/attachment_util.py +4 -2
  7. sapiopycommons/datatype/data_fields.py +23 -1
  8. sapiopycommons/eln/experiment_cache.py +173 -0
  9. sapiopycommons/eln/experiment_handler.py +933 -279
  10. sapiopycommons/eln/experiment_report_util.py +15 -10
  11. sapiopycommons/eln/experiment_step_factory.py +474 -0
  12. sapiopycommons/eln/experiment_tags.py +7 -0
  13. sapiopycommons/eln/plate_designer.py +159 -59
  14. sapiopycommons/eln/step_creation.py +235 -0
  15. sapiopycommons/files/file_bridge.py +76 -0
  16. sapiopycommons/files/file_bridge_handler.py +325 -110
  17. sapiopycommons/files/file_data_handler.py +2 -2
  18. sapiopycommons/files/file_util.py +40 -15
  19. sapiopycommons/files/file_validator.py +6 -5
  20. sapiopycommons/files/file_writer.py +1 -1
  21. sapiopycommons/flowcyto/flow_cyto.py +1 -1
  22. sapiopycommons/general/accession_service.py +3 -3
  23. sapiopycommons/general/aliases.py +51 -28
  24. sapiopycommons/general/audit_log.py +2 -2
  25. sapiopycommons/general/custom_report_util.py +24 -1
  26. sapiopycommons/general/data_structure_util.py +115 -0
  27. sapiopycommons/general/directive_util.py +86 -0
  28. sapiopycommons/general/exceptions.py +41 -2
  29. sapiopycommons/general/popup_util.py +2 -2
  30. sapiopycommons/multimodal/multimodal.py +1 -0
  31. sapiopycommons/processtracking/custom_workflow_handler.py +46 -30
  32. sapiopycommons/recordmodel/record_handler.py +547 -159
  33. sapiopycommons/rules/eln_rule_handler.py +41 -30
  34. sapiopycommons/rules/on_save_rule_handler.py +41 -30
  35. sapiopycommons/samples/aliquot.py +48 -0
  36. sapiopycommons/webhook/webhook_handlers.py +448 -55
  37. sapiopycommons/webhook/webservice_handlers.py +2 -2
  38. {sapiopycommons-2025.4.9a150.dist-info → sapiopycommons-2025.4.9a476.dist-info}/METADATA +1 -1
  39. sapiopycommons-2025.4.9a476.dist-info/RECORD +67 -0
  40. sapiopycommons-2025.4.9a150.dist-info/RECORD +0 -59
  41. {sapiopycommons-2025.4.9a150.dist-info → sapiopycommons-2025.4.9a476.dist-info}/WHEEL +0 -0
  42. {sapiopycommons-2025.4.9a150.dist-info → sapiopycommons-2025.4.9a476.dist-info}/licenses/LICENSE +0 -0
@@ -1,18 +1,24 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import io
4
+ import re
5
+ import warnings
6
+ from copy import copy
7
+ from typing import Iterable, TypeAlias, Any, Callable, Container, Collection
4
8
  from weakref import WeakValueDictionary
5
9
 
6
10
  from requests import ReadTimeout
7
11
  from sapiopylib.rest.ClientCallbackService import ClientCallback
8
12
  from sapiopylib.rest.DataMgmtService import DataMgmtServer
13
+ from sapiopylib.rest.DataTypeService import DataTypeManager
9
14
  from sapiopylib.rest.User import SapioUser
10
15
  from sapiopylib.rest.pojo.CustomReport import CustomReport, CustomReportCriteria
11
16
  from sapiopylib.rest.pojo.DataRecord import DataRecord
12
17
  from sapiopylib.rest.pojo.datatype.DataType import DataTypeDefinition
13
18
  from sapiopylib.rest.pojo.datatype.DataTypeLayout import DataTypeLayout
14
19
  from sapiopylib.rest.pojo.datatype.FieldDefinition import AbstractVeloxFieldDefinition, VeloxStringFieldDefinition, \
15
- VeloxIntegerFieldDefinition, VeloxDoubleFieldDefinition, FieldDefinitionParser
20
+ VeloxIntegerFieldDefinition, VeloxDoubleFieldDefinition, FieldType
21
+ from sapiopylib.rest.pojo.datatype.TemporaryDataType import TemporaryDataType
16
22
  from sapiopylib.rest.pojo.webhook.ClientCallbackRequest import OptionDialogRequest, ListDialogRequest, \
17
23
  FormEntryDialogRequest, InputDialogCriteria, TableEntryDialogRequest, ESigningRequestPojo, \
18
24
  DataRecordDialogRequest, InputSelectionRequest, FilePromptRequest, MultiFilePromptRequest, \
@@ -22,20 +28,32 @@ from sapiopylib.rest.pojo.webhook.WebhookEnums import FormAccessLevel, ScanToSel
22
28
  from sapiopylib.rest.utils.DataTypeCacheManager import DataTypeCacheManager
23
29
  from sapiopylib.rest.utils.FormBuilder import FormBuilder
24
30
  from sapiopylib.rest.utils.recorddatasinks import InMemoryRecordDataSink
31
+ from sapiopylib.rest.utils.recordmodel.PyRecordModel import PyRecordModel
25
32
  from sapiopylib.rest.utils.recordmodel.RecordModelWrapper import WrappedType
26
33
 
34
+ from sapiopycommons.callbacks.field_builder import FieldBuilder, AnyFieldInfo
27
35
  from sapiopycommons.files.file_util import FileUtil
28
36
  from sapiopycommons.general.aliases import FieldMap, SapioRecord, AliasUtil, RecordIdentifier, FieldValue, \
29
- UserIdentifier
37
+ UserIdentifier, FieldIdentifier, DataTypeIdentifier
30
38
  from sapiopycommons.general.custom_report_util import CustomReportUtil
31
39
  from sapiopycommons.general.exceptions import SapioUserCancelledException, SapioException, SapioUserErrorException, \
32
40
  SapioDialogTimeoutException
33
41
  from sapiopycommons.recordmodel.record_handler import RecordHandler
34
42
 
43
+ DataTypeLayoutIdentifier: TypeAlias = DataTypeLayout | str | None
35
44
 
45
+
46
+ # CR-47521: Updated various parameter type hints from list or Iterable to more specific type hints.
47
+ # If we need to iterate over the parameter, then it is Iterable.
48
+ # If we need to see if the parameter contains a value, then it is Container.
49
+ # If the length/size of the parameter is needed, then it is Collection.
50
+ # If we need to access the parameter by an index, then it is Sequence. (This excludes sets and dictionaries, so it's
51
+ # probably better to accept a Collection then cast the parameter to a list if you need to get an element from it.)
36
52
  class CallbackUtil:
37
53
  user: SapioUser
38
54
  callback: ClientCallback
55
+ rec_handler: RecordHandler
56
+ dt_man: DataTypeManager
39
57
  dt_cache: DataTypeCacheManager
40
58
  _original_timeout: int
41
59
  timeout_seconds: int
@@ -45,6 +63,10 @@ class CallbackUtil:
45
63
  __instances: WeakValueDictionary[SapioUser, CallbackUtil] = WeakValueDictionary()
46
64
  __initialized: bool
47
65
 
66
+ # TODO: Remove this if ever the DataTypeCacheManager starts handling it.
67
+ __layouts: dict[str, dict[str, DataTypeLayout]]
68
+ """A cache for data type layouts that have been requested by this CallbackUtil."""
69
+
48
70
  def __new__(cls, context: UserIdentifier):
49
71
  """
50
72
  :param context: The current webhook context or a user object to send requests from.
@@ -67,11 +89,14 @@ class CallbackUtil:
67
89
 
68
90
  self.user = AliasUtil.to_sapio_user(context)
69
91
  self.callback = DataMgmtServer.get_client_callback(self.user)
92
+ self.rec_handler = RecordHandler(self.user)
93
+ self.dt_man = DataMgmtServer.get_data_type_manager(self.user)
70
94
  self.dt_cache = DataTypeCacheManager(self.user)
71
95
  self._original_timeout = self.user.timeout_seconds
72
96
  self.timeout_seconds = self.user.timeout_seconds
73
97
  self.width_pixels = None
74
98
  self.width_percent = None
99
+ self.__layouts = {}
75
100
 
76
101
  def set_dialog_width(self, width_pixels: int | None = None, width_percent: float | None = None):
77
102
  """
@@ -101,7 +126,7 @@ class CallbackUtil:
101
126
  """
102
127
  Display a toaster popup in the bottom right corner of the user's screen.
103
128
 
104
- :param message: The message to display in the toaster.
129
+ :param message: The message to display in the toaster. This can be formatted using HTML elements.
105
130
  :param title: The title to display at the top of the toaster.
106
131
  :param popup_type: The popup type to use for the toaster. This controls the color that the toaster appears with.
107
132
  Info is blue, Success is green, Warning is yellow, and Error is red
@@ -113,7 +138,7 @@ class CallbackUtil:
113
138
  Display an info message to the user in a dialog. Repeated calls to this function will append the new messages
114
139
  to the same dialog if it is still opened by the user.
115
140
 
116
- :param message: The message to display to the user.
141
+ :param message: The message to display to the user. This can be formatted using HTML elements.
117
142
  """
118
143
  self.callback.display_info(message)
119
144
 
@@ -122,7 +147,7 @@ class CallbackUtil:
122
147
  Display a warning message to the user in a dialog. Repeated calls to this function will append the new messages
123
148
  to the same dialog if it is still opened by the user.
124
149
 
125
- :param message: The message to display to the user.
150
+ :param message: The message to display to the user. This can be formatted using HTML elements.
126
151
  """
127
152
  self.callback.display_warning(message)
128
153
 
@@ -131,17 +156,17 @@ class CallbackUtil:
131
156
  Display an error message to the user in a dialog. Repeated calls to this function will append the new messages
132
157
  to the same dialog if it is still opened by the user.
133
158
 
134
- :param message: The message to display to the user.
159
+ :param message: The message to display to the user. This can be formatted using HTML elements.
135
160
  """
136
161
  self.callback.display_error(message)
137
162
 
138
- def option_dialog(self, title: str, msg: str, options: list[str], default_option: int = 0,
163
+ def option_dialog(self, title: str, msg: str, options: Iterable[str], default_option: int = 0,
139
164
  user_can_cancel: bool = False) -> str:
140
165
  """
141
166
  Create an option dialog with the given options for the user to choose from.
142
167
 
143
168
  :param title: The title of the dialog.
144
- :param msg: The message to display in the dialog.
169
+ :param msg: The message to display in the dialog. This can be formatted using HTML elements.
145
170
  :param options: The button options that the user has to choose from.
146
171
  :param default_option: The index of the option in the options list that defaults as the first choice.
147
172
  :param user_can_cancel: True if the user is able to click the X to close the dialog. False if the user cannot
@@ -149,36 +174,35 @@ class CallbackUtil:
149
174
  SapioUserCancelledException is thrown.
150
175
  :return: The name of the button that the user selected.
151
176
  """
177
+ if not options:
178
+ raise SapioException("No options provided.")
179
+
180
+ # Convert the iterable of options to a list of options.
181
+ options: list[str] = list(options)
182
+
183
+ # Send the request to the user.
152
184
  request = OptionDialogRequest(title, msg, options, default_option, user_can_cancel,
153
185
  width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
154
- try:
155
- self.user.timeout_seconds = self.timeout_seconds
156
- response: int | None = self.callback.show_option_dialog(request)
157
- except ReadTimeout:
158
- raise SapioDialogTimeoutException()
159
- finally:
160
- self.user.timeout_seconds = self._original_timeout
161
- if response is None:
162
- raise SapioUserCancelledException()
186
+ response: int = self.__handle_dialog_request(request, self.callback.show_option_dialog)
163
187
  return options[response]
164
188
 
165
189
  def ok_dialog(self, title: str, msg: str) -> None:
166
190
  """
167
191
  Create an option dialog where the only option is "OK". Doesn't allow the user to cancel the
168
- dialog using the X at the top right corner. Returns nothing.
192
+ dialog using the X in the top right corner. Returns nothing.
169
193
 
170
194
  :param title: The title of the dialog.
171
- :param msg: The message to display in the dialog.
195
+ :param msg: The message to display in the dialog. This can be formatted using HTML elements.
172
196
  """
173
197
  self.option_dialog(title, msg, ["OK"], 0, False)
174
198
 
175
199
  def ok_cancel_dialog(self, title: str, msg: str, default_ok: bool = True) -> bool:
176
200
  """
177
201
  Create an option dialog where the only options are "OK" and "Cancel". Doesn't allow the user to cancel the
178
- dialog using the X at the top right corner.
202
+ dialog using the X in the top right corner.
179
203
 
180
204
  :param title: The title of the dialog.
181
- :param msg: The message to display in the dialog.
205
+ :param msg: The message to display in the dialog. This can be formatted using HTML elements.
182
206
  :param default_ok: If true, "OK" is the default choice. Otherwise, the default choice is "Cancel".
183
207
  :return: True if the user selected OK. False if the user selected Cancel.
184
208
  """
@@ -187,17 +211,25 @@ class CallbackUtil:
187
211
  def yes_no_dialog(self, title: str, msg: str, default_yes: bool = True) -> bool:
188
212
  """
189
213
  Create an option dialog where the only options are "Yes" and "No". Doesn't allow the user to cancel the
190
- dialog using the X at the top right corner.
214
+ dialog using the X in the top right corner.
191
215
 
192
216
  :param title: The title of the dialog.
193
- :param msg: The message to display in the dialog.
217
+ :param msg: The message to display in the dialog. This can be formatted using HTML elements.
194
218
  :param default_yes: If true, "Yes" is the default choice. Otherwise, the default choice is "No".
195
219
  :return: True if the user selected Yes. False if the user selected No.
196
220
  """
197
221
  return self.option_dialog(title, msg, ["Yes", "No"], 0 if default_yes else 1, False) == "Yes"
198
222
 
199
- def list_dialog(self, title: str, options: list[str], multi_select: bool = False,
200
- preselected_values: list[str] | None = None) -> list[str]:
223
+ # CR-47310: Add a parameter to the list, input, selection, and e-sign dialog functions to control reprompting the
224
+ # user if no input/selection/valid credentials are provided.
225
+ def list_dialog(self,
226
+ title: str,
227
+ options: Iterable[str],
228
+ multi_select: bool = False,
229
+ preselected_values: Iterable[str] | None = None,
230
+ *,
231
+ require_selection: bool = False,
232
+ repeat_message: str | None = "Please provide a selection to continue.") -> list[str]:
201
233
  """
202
234
  Create a list dialog with the given options for the user to choose from.
203
235
 
@@ -206,29 +238,37 @@ class CallbackUtil:
206
238
  :param multi_select: Whether the user is able to select multiple options from the list.
207
239
  :param preselected_values: A list of values that will already be selected when the list dialog is created. The
208
240
  user can unselect these values if they want to.
241
+ :param require_selection: If true, the request will be re-sent if the user submits the dialog without making
242
+ a selection.
243
+ :param repeat_message: If require_selection is true and a repeat_message is provided, then that message appears
244
+ as toaster text if the dialog is repeated.
209
245
  :return: The list of options that the user selected.
210
246
  """
211
- request = ListDialogRequest(title, multi_select, options, preselected_values,
247
+ if not options:
248
+ raise SapioException("No options provided.")
249
+
250
+ # Send the request to the user.
251
+ request = ListDialogRequest(title, multi_select, list(options),
252
+ list(preselected_values) if preselected_values else None,
212
253
  width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
213
- try:
214
- self.user.timeout_seconds = self.timeout_seconds
215
- response: list[str] | None = self.callback.show_list_dialog(request)
216
- except ReadTimeout:
217
- raise SapioDialogTimeoutException()
218
- finally:
219
- self.user.timeout_seconds = self._original_timeout
220
- if response is None:
221
- raise SapioUserCancelledException()
254
+
255
+ # If require_selection is true, repeat the request if the user didn't make a selection.
256
+ while True:
257
+ response: list[str] = self.__handle_dialog_request(request, self.callback.show_list_dialog)
258
+ if not require_selection or response:
259
+ break
260
+ if repeat_message:
261
+ self.toaster_popup(repeat_message, popup_type=PopupType.Warning)
222
262
  return response
223
263
 
224
264
  def form_dialog(self,
225
265
  title: str,
226
266
  msg: str,
227
- fields: list[AbstractVeloxFieldDefinition],
267
+ fields: Iterable[AbstractVeloxFieldDefinition],
228
268
  values: FieldMap = None,
229
269
  column_positions: dict[str, tuple[int, int]] = None,
230
270
  *,
231
- data_type: str = "Default",
271
+ data_type: DataTypeIdentifier = "Default",
232
272
  display_name: str | None = None,
233
273
  plural_display_name: str | None = None) -> FieldMap:
234
274
  """
@@ -236,7 +276,7 @@ class CallbackUtil:
236
276
  provide the definitions of every field in the form.
237
277
 
238
278
  :param title: The title of the dialog.
239
- :param msg: The message to display at the top of the form.
279
+ :param msg: The message to display at the top of the form. This can be formatted using HTML elements.
240
280
  :param fields: The definitions of the fields to display in the form. Fields will be displayed in the order they
241
281
  are provided in this list.
242
282
  :param values: Sets the default values of the fields.
@@ -250,133 +290,253 @@ class CallbackUtil:
250
290
  :return: A dictionary mapping the data field names of the given field definitions to the response value from
251
291
  the user for that field.
252
292
  """
253
- if display_name is None:
254
- display_name = data_type
255
- if plural_display_name is None:
256
- plural_display_name = display_name + "s"
293
+ # Build a temporary data type for the request.
294
+ temp_dt = self.__temp_dt_from_field_defs(data_type, display_name, plural_display_name, fields, column_positions)
257
295
 
258
- builder = FormBuilder(data_type, display_name, plural_display_name)
259
- for field_def in fields:
260
- field_name = field_def.data_field_name
261
- column: int = 0
262
- span: int = 4
263
- if column_positions and field_name in column_positions:
264
- position = column_positions.get(field_name)
265
- column = position[0]
266
- span = position[1]
267
- builder.add_field(field_def, column, span)
268
-
269
- request = FormEntryDialogRequest(title, msg, builder.get_temporary_data_type(), values,
296
+ # Send the request to the user.
297
+ request = FormEntryDialogRequest(title, msg, temp_dt, values,
270
298
  width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
271
- try:
272
- self.user.timeout_seconds = self.timeout_seconds
273
- response: FieldMap | None = self.callback.show_form_entry_dialog(request)
274
- except ReadTimeout:
275
- raise SapioDialogTimeoutException()
276
- finally:
277
- self.user.timeout_seconds = self._original_timeout
278
- if response is None:
279
- raise SapioUserCancelledException()
299
+ response: FieldMap = self.__handle_dialog_request(request, self.callback.show_form_entry_dialog)
280
300
  return response
281
301
 
282
302
  def record_form_dialog(self,
283
303
  title: str,
284
304
  msg: str,
285
- fields: list[str],
305
+ fields: Iterable[FieldIdentifier | FieldFilterCriteria] | DataTypeLayoutIdentifier,
286
306
  record: SapioRecord,
287
- column_positions: dict[str, tuple[int, int]] = None,
288
- editable: bool | None = True) -> FieldMap:
307
+ column_positions: dict[str, tuple[int, int]] | None = None,
308
+ editable=None,
309
+ *,
310
+ default_modifier: FieldModifier | None = None,
311
+ field_modifiers: dict[FieldIdentifier, FieldModifier] | None = None) -> FieldMap:
289
312
  """
290
313
  Create a form dialog where the user may input data into the fields of the form. The form is constructed from
291
- a given record. Provided field names must match fields on the definition of the data type of the given record.
292
- The fields that are displayed will have their default value be that of the fields on the given record.
314
+ a given record.
293
315
 
294
316
  Makes webservice calls to get the data type definition and fields of the given record if they weren't
295
317
  previously cached.
296
318
 
297
319
  :param title: The title of the dialog.
298
- :param msg: The message to display in the dialog.
299
- :param fields: The data field names of the fields from the record to display in the form. Fields will be
300
- displayed in the order they are provided in this list.
320
+ :param msg: The message to display in the dialog. This can be formatted using HTML elements.
321
+ :param fields: The names of the fields to display as columns in the table. These names must match field names on
322
+ the data type of the provided record. Provided field names may also be extension fields of the form
323
+ [Extension Data Type Name].[Data Field Name]. This parameter may also be an identifier for a data type
324
+ layout from the data type of the provided records. If None, then the layout assigned to the current user's
325
+ group for this data type will be used.
301
326
  :param record: The record to display the values of.
302
327
  :param column_positions: If a tuple is provided for a field name, alters that field's column position and column
303
- span. (Field order is still determined by the fields list.)
304
- :param editable: If true, all fields are displayed as editable. If false, all fields are displayed as
305
- uneditable. If none, only those fields that are defined as editable by the data designer will be editable.
328
+ span. (Field order is still determined by the fields list.) Has no effect if the fields parameter provides
329
+ a data type layout.
330
+ :param editable: DEPRECATED. Has no effect.
331
+ :param default_modifier: A default field modifier that will be applied to the given fields. This can be used to
332
+ make field definitions from the system behave differently than their system values. If this value is None,
333
+ then a default field modifier is created that causes all specified fields to be both visible and not key
334
+ fields. (Key fields get displayed first before any non-key fields in tables, so the key field setting is
335
+ disabled by default in order to have the columns in the table respect the order of the fields as they are
336
+ provided to this function.)
337
+ :param field_modifiers: A mapping of data field name to field modifier for changes that should be applied to
338
+ the matching field. If a data field name is not present in the provided dict, or the provided dictionary is
339
+ None, then the default modifier will be used.
306
340
  :return: A dictionary mapping the data field names of the given field definitions to the response value from
307
341
  the user for that field.
308
342
  """
309
- # Get the field definitions of the data type.
310
- data_type: str = record.data_type_name
311
- type_def: DataTypeDefinition = self.dt_cache.get_data_type(data_type)
312
- field_defs: dict[str, AbstractVeloxFieldDefinition] = self.dt_cache.get_fields_for_type(data_type)
343
+ # CR-47313: Replace the editable boolean with the default_modifier and field_modifiers parameters.
344
+ if editable is not None:
345
+ warnings.warn("The editable parameter is deprecated. Use the default_modifier and field_modifiers "
346
+ "parameters instead.", DeprecationWarning)
313
347
 
314
- # Make everything visible, because presumably the caller gave a field name because they want it to be seen.
315
- modifier = FieldModifier(visible=True, editable=editable)
316
-
317
- # Build the form using only those fields that are desired.
318
- values: dict[str, FieldValue] = {}
319
- builder = FormBuilder(data_type, type_def.display_name, type_def.plural_display_name)
320
- for field_name in fields:
321
- field_def = field_defs.get(field_name)
322
- if field_def is None:
323
- raise SapioException(f"No field of name \"{field_name}\" in field definitions of type \"{data_type}\"")
324
- values[field_name] = record.get_field_value(field_name)
325
- column: int = 0
326
- span: int = 4
327
- if column_positions and field_name in column_positions:
328
- position = column_positions.get(field_name)
329
- column = position[0]
330
- span = position[1]
331
- builder.add_field(modifier.modify_field(field_def), column, span)
348
+ # Get the data type name and field values from the provided record.
349
+ data_type: str = AliasUtil.to_data_type_name(record)
350
+ values: dict[str, FieldValue] = AliasUtil.to_field_map(record)
332
351
 
333
- request = FormEntryDialogRequest(title, msg, builder.get_temporary_data_type(), values,
352
+ # Set the default modifier to make all fields visible and not key if no default was provided.
353
+ if default_modifier is None:
354
+ default_modifier = FieldModifier(visible=True, key_field=False)
355
+ # To make things simpler, treat null field modifiers as an empty dict.
356
+ if field_modifiers is None:
357
+ field_modifiers = {}
358
+ else:
359
+ field_modifiers: dict[str, FieldModifier] = AliasUtil.to_data_field_names_dict(field_modifiers)
360
+
361
+ # Build a temporary data type for the request.
362
+ if isinstance(fields, DataTypeLayoutIdentifier):
363
+ temp_dt = self.__temp_dt_from_layout(data_type, fields, default_modifier, field_modifiers)
364
+ else:
365
+ temp_dt = self.__temp_dt_from_field_names(data_type, fields, column_positions,
366
+ default_modifier, field_modifiers)
367
+
368
+ # Send the request to the user.
369
+ request = FormEntryDialogRequest(title, msg, temp_dt, values,
334
370
  width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
335
- try:
336
- self.user.timeout_seconds = self.timeout_seconds
337
- response: FieldMap | None = self.callback.show_form_entry_dialog(request)
338
- except ReadTimeout:
339
- raise SapioDialogTimeoutException()
340
- finally:
341
- self.user.timeout_seconds = self._original_timeout
342
- if response is None:
343
- raise SapioUserCancelledException()
371
+ response: FieldMap = self.__handle_dialog_request(request, self.callback.show_form_entry_dialog)
344
372
  return response
345
373
 
346
- def input_dialog(self, title: str, msg: str, field: AbstractVeloxFieldDefinition) -> FieldValue:
374
+ # FR-47314: Create record form and table dialogs for updating or creating records.
375
+ def set_record_form_dialog(self,
376
+ title: str,
377
+ msg: str,
378
+ fields: Iterable[FieldIdentifier | FieldFilterCriteria] | DataTypeLayoutIdentifier,
379
+ record: SapioRecord,
380
+ column_positions: dict[str, tuple[int, int]] | None = None,
381
+ *,
382
+ default_modifier: FieldModifier | None = None,
383
+ field_modifiers: dict[FieldIdentifier, FieldModifier] | None = None) -> None:
384
+ """
385
+ Create a form dialog where the user may input data into the fields of the form. The form is constructed from
386
+ a given record. After the user submits this dialog, the values that the user provided are used to update the
387
+ provided record.
388
+
389
+ Makes webservice calls to get the data type definition and fields of the given record if they weren't
390
+ previously cached.
391
+
392
+ :param title: The title of the dialog.
393
+ :param msg: The message to display in the dialog. This can be formatted using HTML elements.
394
+ :param fields: The names of the fields to display as columns in the table. These names must match field names on
395
+ the data type of the provided record. Provided field names may also be extension fields of the form
396
+ [Extension Data Type Name].[Data Field Name]. This parameter may also be an identifier for a data type
397
+ layout from the data type of the provided records. If None, then the layout assigned to the current user's
398
+ group for this data type will be used.
399
+ :param record: The record to display and update the values of.
400
+ :param column_positions: If a tuple is provided for a field name, alters that field's column position and column
401
+ span. (Field order is still determined by the fields list.) Has no effect if the fields parameter provides
402
+ a data type layout.
403
+ :param default_modifier: A default field modifier that will be applied to the given fields. This can be used to
404
+ make field definitions from the system behave differently than their system values. If this value is None,
405
+ then a default field modifier is created that causes all specified fields to be both visible and not key
406
+ fields. (Key fields get displayed first before any non-key fields in tables, so the key field setting is
407
+ disabled by default in order to have the columns in the table respect the order of the fields as they are
408
+ provided to this function.)
409
+ :param field_modifiers: A mapping of data field name to field modifier for changes that should be applied to
410
+ the matching field. If a data field name is not present in the provided dict, or the provided dictionary is
411
+ None, then the default modifier will be used.
412
+ """
413
+ results: FieldMap = self.record_form_dialog(title, msg, fields, record, column_positions,
414
+ default_modifier=default_modifier, field_modifiers=field_modifiers)
415
+ record.set_field_values(results)
416
+
417
+ # CR-47491: Support providing a data type name string to receive PyRecordModels instead of requiring a WrapperType.
418
+ def create_record_form_dialog(self,
419
+ title: str,
420
+ msg: str,
421
+ fields: Iterable[FieldIdentifier | FieldFilterCriteria] | DataTypeLayoutIdentifier,
422
+ wrapper_type: type[WrappedType] | str,
423
+ column_positions: dict[str, tuple[int, int]] | None = None,
424
+ *,
425
+ default_modifier: FieldModifier | None = None,
426
+ field_modifiers: dict[FieldIdentifier, FieldModifier] | None = None) \
427
+ -> WrappedType | PyRecordModel:
428
+ """
429
+ Create a form dialog where the user may input data into the fields of the form. The form is constructed from
430
+ a record that is created using the given record model wrapper. After the user submits this dialog, the values
431
+ that the user provided are used to update the created record.
432
+
433
+ Makes webservice calls to get the data type definition and fields of the given record if they weren't
434
+ previously cached.
435
+
436
+ :param title: The title of the dialog.
437
+ :param msg: The message to display in the dialog. This can be formatted using HTML elements.
438
+ :param fields: The names of the fields to display as columns in the table. These names must match field names on
439
+ the data type of the provided wrapper. Provided field names may also be extension fields of the form
440
+ [Extension Data Type Name].[Data Field Name]. This parameter may also be an identifier for a data type
441
+ layout from the data type of the provided records. If None, then the layout assigned to the current user's
442
+ group for this data type will be used. FieldFilterCriteria may also be provided in lieu of field names.
443
+ :param wrapper_type: The record model wrapper or data type name of the record to be created and updated.
444
+ If a data type name is provided, the returned record will be a PyRecordModel instead of a
445
+ WrappedRecordModel.
446
+ :param column_positions: If a tuple is provided for a field name, alters that field's column position and column
447
+ span. (Field order is still determined by the fields list.) Has no effect if the fields parameter provides
448
+ a data type layout.
449
+ :param default_modifier: A default field modifier that will be applied to the given fields. This can be used to
450
+ make field definitions from the system behave differently than their system values. If this value is None,
451
+ then a default field modifier is created that causes all specified fields to be both visible and not key
452
+ fields. (Key fields get displayed first before any non-key fields in tables, so the key field setting is
453
+ disabled by default in order to have the columns in the table respect the order of the fields as they are
454
+ provided to this function.)
455
+ :param field_modifiers: A mapping of data field name to field modifier for changes that should be applied to
456
+ the matching field. If a data field name is not present in the provided dict, or the provided dictionary is
457
+ None, then the default modifier will be used.
458
+ :return: The record model that was created and updated by the user.
459
+ """
460
+ record: WrappedType | PyRecordModel = self.rec_handler.add_model(wrapper_type)
461
+ self.set_record_form_dialog(title, msg, fields, record, column_positions,
462
+ default_modifier=default_modifier, field_modifiers=field_modifiers)
463
+ return record
464
+
465
+ def input_dialog(self,
466
+ title: str,
467
+ msg: str,
468
+ field: AbstractVeloxFieldDefinition,
469
+ *,
470
+ require_input: bool = False,
471
+ repeat_message: str | None = "Please provide a value to continue.") -> FieldValue:
347
472
  """
348
473
  Create an input dialog where the user must input data for a singular field.
349
474
 
350
475
  :param title: The title of the dialog.
351
- :param msg: The message to display in the dialog.
476
+ :param msg: The message to display in the dialog. This can be formatted using HTML elements.
352
477
  :param field: The definition for a field that the user must provide input to.
478
+ :param require_input: If true, the request will be re-sent if the user submits the dialog without providing an
479
+ input field value.
480
+ :param repeat_message: If require_input is true and a repeat_message is provided, then that message appears
481
+ as toaster text if the dialog is repeated.
353
482
  :return: The response value from the user for the given field.
354
483
  """
484
+ # Send the request to the user.
355
485
  request = InputDialogCriteria(title, msg, field,
356
486
  width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
357
- try:
358
- self.user.timeout_seconds = self.timeout_seconds
359
- response: FieldValue | None = self.callback.show_input_dialog(request)
360
- except ReadTimeout:
361
- raise SapioDialogTimeoutException()
362
- finally:
363
- self.user.timeout_seconds = self._original_timeout
364
- if response is None:
365
- raise SapioUserCancelledException()
487
+
488
+ # If require_input is true, repeat the request if the user didn't provide a field value.
489
+ while True:
490
+ try:
491
+ self.user.timeout_seconds = self.timeout_seconds
492
+ # It's not possible to distinguish between the user cancelling this dialog and submitting the dialog
493
+ # with no input if the ClientCallback show_input_dialog function is used, as both cases just return
494
+ # None. Therefore, in order to be able to make that distinction, we need to call the endpoint without
495
+ # ClientCallback and get the raw response object.
496
+ raw_response = self.user.post('/clientcallback/showInputDialog', payload=request.to_json())
497
+ # A response status code of 204 is what represents a cancelled dialog.
498
+ if raw_response.status_code == 204:
499
+ raise SapioUserCancelledException()
500
+ self.user.raise_for_status(raw_response)
501
+ json_dct: dict | None = self.user.get_json_data_or_none(raw_response)
502
+ response: FieldValue = json_dct['result'] if json_dct else None
503
+ except ReadTimeout:
504
+ raise SapioDialogTimeoutException()
505
+ finally:
506
+ self.user.timeout_seconds = self._original_timeout
507
+ # String fields that the user didn't provide will return as an empty string instead of a None response.
508
+ is_str: bool = isinstance(response, str)
509
+ if not require_input or (is_str and response) or (not is_str and response is not None):
510
+ break
511
+ if repeat_message:
512
+ self.toaster_popup(repeat_message, popup_type=PopupType.Warning)
366
513
  return response
367
514
 
368
- def string_input_dialog(self, title: str, msg: str, field_name: str, default_value: str | None = None,
369
- max_length: int | None = None, editable: bool = True, **kwargs) -> str:
515
+ def string_input_dialog(self,
516
+ title: str,
517
+ msg: str,
518
+ field_name: str,
519
+ default_value: str | None = None,
520
+ max_length: int | None = None,
521
+ editable: bool = True,
522
+ *,
523
+ require_input: bool = False,
524
+ repeat_message: str | None = "Please provide a value to continue.",
525
+ **kwargs) -> str:
370
526
  """
371
527
  Create an input dialog where the user must input data for a singular text field.
372
528
 
373
529
  :param title: The title of the dialog.
374
- :param msg: The message to display in the dialog.
530
+ :param msg: The message to display in the dialog. This can be formatted using HTML elements.
375
531
  :param field_name: The name and display name of the string field.
376
532
  :param default_value: The default value to place into the string field, if any.
377
533
  :param max_length: The max length of the string value. If not provided, uses the length of the default value.
378
534
  If neither this nor a default value are provided, defaults to 100 characters.
379
535
  :param editable: Whether the field is editable by the user.
536
+ :param require_input: If true, the request will be re-sent if the user submits the dialog without making
537
+ a selection.
538
+ :param repeat_message: If require_input is true and a repeat_message is provided, then that message appears
539
+ as toaster text if the dialog is repeated.
380
540
  :param kwargs: Any additional keyword arguments to pass to the field definition.
381
541
  :return: The string that the user input into the dialog.
382
542
  """
@@ -384,21 +544,36 @@ class CallbackUtil:
384
544
  max_length = len(default_value) if default_value else 100
385
545
  field = VeloxStringFieldDefinition("Input", field_name, field_name, default_value=default_value,
386
546
  max_length=max_length, editable=editable, **kwargs)
387
- return self.input_dialog(title, msg, field)
388
-
389
- def integer_input_dialog(self, title: str, msg: str, field_name: str, default_value: int = None,
390
- min_value: int = -10000, max_value: int = 10000, editable: bool = True, **kwargs) -> int:
547
+ return self.input_dialog(title, msg, field,
548
+ require_input=require_input, repeat_message=repeat_message)
549
+
550
+ def integer_input_dialog(self,
551
+ title: str,
552
+ msg: str,
553
+ field_name: str,
554
+ default_value: int = None,
555
+ min_value: int = -10000,
556
+ max_value: int = 10000,
557
+ editable: bool = True,
558
+ *,
559
+ require_input: bool = False,
560
+ repeat_message: str | None = "Please provide a value to continue.",
561
+ **kwargs) -> int:
391
562
  """
392
563
  Create an input dialog where the user must input data for a singular integer field.
393
564
 
394
565
  :param title: The title of the dialog.
395
- :param msg: The message to display in the dialog.
566
+ :param msg: The message to display in the dialog. This can be formatted using HTML elements.
396
567
  :param field_name: The name and display name of the integer field.
397
568
  :param default_value: The default value to place into the integer field. If not provided, defaults to the 0 or
398
569
  the minimum value, whichever is higher.
399
570
  :param min_value: The minimum allowed value of the input.
400
571
  :param max_value: The maximum allowed value of the input.
401
572
  :param editable: Whether the field is editable by the user.
573
+ :param require_input: If true, the request will be re-sent if the user submits the dialog without making
574
+ a selection.
575
+ :param repeat_message: If require_input is true and a repeat_message is provided, then that message appears
576
+ as toaster text if the dialog is repeated.
402
577
  :param kwargs: Any additional keyword arguments to pass to the field definition.
403
578
  :return: The integer that the user input into the dialog.
404
579
  """
@@ -406,22 +581,36 @@ class CallbackUtil:
406
581
  default_value = max(0, min_value)
407
582
  field = VeloxIntegerFieldDefinition("Input", field_name, field_name, default_value=default_value,
408
583
  min_value=min_value, max_value=max_value, editable=editable, **kwargs)
409
- return self.input_dialog(title, msg, field)
584
+ return self.input_dialog(title, msg, field,
585
+ require_input=require_input, repeat_message=repeat_message)
410
586
 
411
- def double_input_dialog(self, title: str, msg: str, field_name: str, default_value: float = None,
412
- min_value: float = -10000000, max_value: float = 100000000, editable: bool = True,
587
+ def double_input_dialog(self,
588
+ title: str,
589
+ msg: str,
590
+ field_name: str,
591
+ default_value: float = None,
592
+ min_value: float = -10000000,
593
+ max_value: float = 100000000,
594
+ editable: bool = True,
595
+ *,
596
+ require_input: bool = False,
597
+ repeat_message: str | None = "Please provide a value to continue.",
413
598
  **kwargs) -> float:
414
599
  """
415
600
  Create an input dialog where the user must input data for a singular double field.
416
601
 
417
602
  :param title: The title of the dialog.
418
- :param msg: The message to display in the dialog.
603
+ :param msg: The message to display in the dialog. This can be formatted using HTML elements.
419
604
  :param field_name: The name and display name of the double field.
420
605
  :param default_value: The default value to place into the double field. If not provided, defaults to the 0 or
421
606
  the minimum value, whichever is higher.
422
607
  :param min_value: The minimum allowed value of the input.
423
608
  :param max_value: The maximum allowed value of the input.
424
609
  :param editable: Whether the field is editable by the user.
610
+ :param require_input: If true, the request will be re-sent if the user submits the dialog without making
611
+ a selection.
612
+ :param repeat_message: If require_input is true and a repeat_message is provided, then that message appears
613
+ as toaster text if the dialog is repeated.
425
614
  :param kwargs: Any additional keyword arguments to pass to the field definition.
426
615
  :return: The float that the user input into the dialog.
427
616
  """
@@ -429,25 +618,26 @@ class CallbackUtil:
429
618
  default_value = max(0., min_value)
430
619
  field = VeloxDoubleFieldDefinition("Input", field_name, field_name, default_value=default_value,
431
620
  min_value=min_value, max_value=max_value, editable=editable, **kwargs)
432
- return self.input_dialog(title, msg, field)
621
+ return self.input_dialog(title, msg, field,
622
+ require_input=require_input, repeat_message=repeat_message)
433
623
 
434
624
  def table_dialog(self,
435
625
  title: str,
436
626
  msg: str,
437
- fields: list[AbstractVeloxFieldDefinition],
438
- values: list[FieldMap],
439
- group_by: str | None = None,
440
- image_data: list[bytes] | None = None,
627
+ fields: Iterable[AbstractVeloxFieldDefinition],
628
+ values: Iterable[FieldMap],
441
629
  *,
442
- data_type: str = "Default",
630
+ data_type: DataTypeIdentifier = "Default",
443
631
  display_name: str | None = None,
444
- plural_display_name: str | None = None) -> list[FieldMap]:
632
+ plural_display_name: str | None = None,
633
+ group_by: FieldIdentifier | None = None,
634
+ image_data: Iterable[bytes] | None = None) -> list[FieldMap]:
445
635
  """
446
636
  Create a table dialog where the user may input data into the fields of the table. Requires that the caller
447
637
  provide the definitions of every field in the table.
448
638
 
449
639
  :param title: The title of the dialog.
450
- :param msg: The message to display at the top of the form.
640
+ :param msg: The message to display at the top of the form. This can be formatted using HTML elements.
451
641
  :param fields: The definitions of the fields to display as table columns. Fields will be displayed in the order
452
642
  they are provided in this list.
453
643
  :param values: The values to set for each row of the table.
@@ -463,57 +653,61 @@ class CallbackUtil:
463
653
  :return: A list of dictionaries mapping the data field names of the given field definitions to the response
464
654
  value from the user for that field for each row.
465
655
  """
466
- if display_name is None:
467
- display_name = data_type
468
- if plural_display_name is None:
469
- plural_display_name = display_name + "s"
656
+ if not values:
657
+ raise SapioException("No values provided.")
470
658
 
471
- # Key fields display their columns in order before all non-key fields.
472
- # Unmark key fields so that the column order is respected exactly as the caller provides it.
473
- modifier = FieldModifier(key_field=False)
659
+ # Convert the group_by parameter to a field name.
660
+ if group_by is not None:
661
+ group_by: str = AliasUtil.to_data_field_name(group_by)
474
662
 
475
- builder = FormBuilder(data_type, display_name, plural_display_name)
476
- for field in fields:
477
- builder.add_field(modifier.modify_field(field))
663
+ # Build a temporary data type for the request.
664
+ temp_dt = self.__temp_dt_from_field_defs(data_type, display_name, plural_display_name, fields, None)
665
+ # PR-47376: Mark record_image_assignable as true if image data is provided.
666
+ temp_dt.record_image_assignable = bool(image_data)
478
667
 
479
- request = TableEntryDialogRequest(title, msg, builder.get_temporary_data_type(), values,
668
+ # Send the request to the user.
669
+ request = TableEntryDialogRequest(title, msg, temp_dt, list(values),
480
670
  record_image_data_list=image_data, group_by_field=group_by,
481
671
  width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
482
- try:
483
- self.user.timeout_seconds = self.timeout_seconds
484
- response: list[FieldMap] | None = self.callback.show_table_entry_dialog(request)
485
- except ReadTimeout:
486
- raise SapioDialogTimeoutException()
487
- finally:
488
- self.user.timeout_seconds = self._original_timeout
489
- if response is None:
490
- raise SapioUserCancelledException()
672
+ response: list[FieldMap] = self.__handle_dialog_request(request, self.callback.show_table_entry_dialog)
491
673
  return response
492
674
 
493
675
  def record_table_dialog(self,
494
676
  title: str,
495
677
  msg: str,
496
- fields: list[str],
497
- records: list[SapioRecord],
498
- editable: bool | None = True,
499
- group_by: str | None = None,
500
- image_data: list[bytes] | None = None) -> list[FieldMap]:
678
+ fields: Iterable[FieldIdentifier | FieldFilterCriteria] | DataTypeLayoutIdentifier,
679
+ records: Iterable[SapioRecord],
680
+ editable=None,
681
+ *,
682
+ default_modifier: FieldModifier | None = None,
683
+ field_modifiers: dict[FieldIdentifier, FieldModifier] | None = None,
684
+ group_by: FieldIdentifier | None = None,
685
+ image_data: Iterable[bytes] | None = None) -> list[FieldMap]:
501
686
  """
502
687
  Create a table dialog where the user may input data into the fields of the table. The table is constructed from
503
- a given list of records of a singular type. Provided field names must match fields on the definition of the data
504
- type of the given records. The fields that are displayed will have their default value be that of the fields on
505
- the given records.
688
+ a given list of records of a singular type.
506
689
 
507
690
  Makes webservice calls to get the data type definition and fields of the given records if they weren't
508
691
  previously cached.
509
692
 
510
693
  :param title: The title of the dialog.
511
- :param msg: The message to display in the dialog.
694
+ :param msg: The message to display in the dialog. This can be formatted using HTML elements.
695
+ :param fields: The names of the fields to display as columns in the table. These names must match field names on
696
+ the data type of the provided record. Provided field names may also be extension fields of the form
697
+ [Extension Data Type Name].[Data Field Name]. This parameter may also be an identifier for a data type
698
+ layout from the data type of the provided records. If None, then the layout assigned to the current user's
699
+ group for this data type will be used.
512
700
  :param records: The records to display as rows in the table.
513
- :param fields: The names of the fields to display as columns in the table. Fields will be displayed in the order
514
- they are provided in this list.
515
- :param editable: If true, all fields are displayed as editable. If false, all fields are displayed as
516
- uneditable. If none, only those fields that are defined as editable by the data designer will be editable.
701
+ :param editable: DEPRECATED. Has no effect.
702
+ :param default_modifier: A default field modifier that will be applied to the given fields. This can be used to
703
+ make field definitions from the system behave differently than their system values. If this value is None,
704
+ then a default field modifier is created that causes all specified fields to be both visible and not key
705
+ fields. (Key fields get displayed first before any non-key fields in tables, so the key field setting is
706
+ disabled by default in order to have the columns in the table respect the order of the fields as they are
707
+ provided to this function.)
708
+ :param field_modifiers: A mapping of data field name to field modifier for changes that should be applied to
709
+ the matching field. If a data field name is not present in the provided dict, or the provided dictionary is
710
+ None, then the default modifier will be used.
517
711
  :param group_by: If provided, the created table dialog will be grouped by the field with this name by default.
518
712
  The user may remove this grouping if they want to.
519
713
  :param image_data: The bytes to the images that should be displayed in the rows of the table. Each element in
@@ -521,54 +715,353 @@ class CallbackUtil:
521
715
  :return: A list of dictionaries mapping the data field names of the given field definitions to the response
522
716
  value from the user for that field for each row.
523
717
  """
718
+ # CR-47313: Replace the editable boolean with the default_modifier and field_modifiers parameters.
719
+ if editable is not None:
720
+ warnings.warn("The editable parameter is deprecated. Use the default_modifier and field_modifiers "
721
+ "parameters instead.", DeprecationWarning)
722
+ # Get the data type name and field values from the provided records.
524
723
  if not records:
525
724
  raise SapioException("No records provided.")
526
- data_types: set[str] = {x.data_type_name for x in records}
527
- if len(data_types) > 1:
528
- raise SapioException("Multiple data type names encountered in records list for record table popup.")
529
- data_type: str = data_types.pop()
530
- # Get the field maps from the records.
531
- field_map_list: list[FieldMap] = AliasUtil.to_field_map_lists(records)
532
- # Get the field definitions of the data type.
533
- type_def: DataTypeDefinition = self.dt_cache.get_data_type(data_type)
534
- field_defs: dict[str, AbstractVeloxFieldDefinition] = self.dt_cache.get_fields_for_type(data_type)
725
+ data_type: str = AliasUtil.to_singular_data_type_name(records)
726
+ field_map_list: list[FieldMap] = AliasUtil.to_field_map_list(records)
535
727
 
536
- # Key fields display their columns in order before all non-key fields.
537
- # Unmark key fields so that the column order is respected exactly as the caller provides it.
538
- # Also make everything visible, because presumably the caller gave a field name because they want it to be seen.
539
- modifier = FieldModifier(visible=True, key_field=False, editable=editable)
728
+ # Convert the group_by parameter to a field name.
729
+ if group_by is not None:
730
+ group_by: str = AliasUtil.to_data_field_name(group_by)
540
731
 
541
- # Build the form using only those fields that are desired.
542
- builder = FormBuilder(data_type, type_def.display_name, type_def.plural_display_name)
543
- for field_name in fields:
544
- field_def = field_defs.get(field_name)
545
- if field_def is None:
546
- raise SapioException(f"No field of name \"{field_name}\" in field definitions of type \"{data_type}\"")
547
- builder.add_field(modifier.modify_field(field_def))
548
-
549
- request = TableEntryDialogRequest(title, msg, builder.get_temporary_data_type(), field_map_list,
732
+ # Set the default modifier to make all fields visible and not key if no default was provided.
733
+ if default_modifier is None:
734
+ default_modifier = FieldModifier(visible=True, key_field=False)
735
+ # To make things simpler, treat null field modifiers as an empty dict.
736
+ if field_modifiers is None:
737
+ field_modifiers = {}
738
+ else:
739
+ field_modifiers: dict[str, FieldModifier] = AliasUtil.to_data_field_names_dict(field_modifiers)
740
+
741
+ # Build a temporary data type for the request.
742
+ if isinstance(fields, DataTypeLayoutIdentifier):
743
+ temp_dt = self.__temp_dt_from_layout(data_type, fields, default_modifier, field_modifiers)
744
+ else:
745
+ temp_dt = self.__temp_dt_from_field_names(data_type, fields, None, default_modifier, field_modifiers)
746
+ temp_dt.record_image_assignable = bool(image_data)
747
+
748
+ # Send the request to the user.
749
+ request = TableEntryDialogRequest(title, msg, temp_dt, field_map_list,
550
750
  record_image_data_list=image_data, group_by_field=group_by,
551
751
  width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
552
- try:
553
- self.user.timeout_seconds = self.timeout_seconds
554
- response: list[FieldMap] | None = self.callback.show_table_entry_dialog(request)
555
- except ReadTimeout:
556
- raise SapioDialogTimeoutException()
557
- finally:
558
- self.user.timeout_seconds = self._original_timeout
559
- if response is None:
560
- raise SapioUserCancelledException()
752
+ response: list[FieldMap] = self.__handle_dialog_request(request, self.callback.show_table_entry_dialog)
561
753
  return response
562
754
 
755
+ # FR-47314: Create record form and table dialogs for updating or creating records.
756
+ def set_record_table_dialog(self,
757
+ title: str,
758
+ msg: str,
759
+ fields: Iterable[FieldValue] | DataTypeLayoutIdentifier,
760
+ records: Iterable[SapioRecord],
761
+ *,
762
+ default_modifier: FieldModifier | None = None,
763
+ field_modifiers: dict[FieldIdentifier, FieldModifier] | None = None,
764
+ group_by: FieldIdentifier | None = None,
765
+ image_data: Iterable[bytes] | None = None):
766
+ """
767
+ Create a table dialog where the user may input data into the fields of the table. The table is constructed from
768
+ a given list of records of a singular type. After the user submits this dialog, the values that the user
769
+ provided are used to update the provided records.
770
+
771
+ Makes webservice calls to get the data type definition and fields of the given records if they weren't
772
+ previously cached.
773
+
774
+ :param title: The title of the dialog.
775
+ :param msg: The message to display in the dialog. This can be formatted using HTML elements.
776
+ :param fields: The names of the fields to display as columns in the table. These names must match field names on
777
+ the data type of the provided record. Provided field names may also be extension fields of the form
778
+ [Extension Data Type Name].[Data Field Name]. This parameter may also be an identifier for a data type
779
+ layout from the data type of the provided records. If None, then the layout assigned to the current user's
780
+ group for this data type will be used.
781
+ :param records: The records to display as rows in the table and update the values of.
782
+ :param default_modifier: A default field modifier that will be applied to the given fields. This can be used to
783
+ make field definitions from the system behave differently than their system values. If this value is None,
784
+ then a default field modifier is created that causes all specified fields to be both visible and not key
785
+ fields. (Key fields get displayed first before any non-key fields in tables, so the key field setting is
786
+ disabled by default in order to have the columns in the table respect the order of the fields as they are
787
+ provided to this function.)
788
+ :param field_modifiers: A mapping of data field name to field modifier for changes that should be applied to
789
+ the matching field. If a data field name is not present in the provided dict, or the provided dictionary is
790
+ None, then the default modifier will be used.
791
+ :param group_by: If provided, the created table dialog will be grouped by the field with this name by default.
792
+ The user may remove this grouping if they want to.
793
+ :param image_data: The bytes to the images that should be displayed in the rows of the table. Each element in
794
+ the image data list corresponds to the element at the same index in the records list.
795
+ """
796
+ results: list[FieldMap] = self.record_table_dialog(title, msg, fields, records,
797
+ default_modifier=default_modifier,
798
+ field_modifiers=field_modifiers,
799
+ group_by=group_by, image_data=image_data)
800
+ records_by_id: dict[int, SapioRecord] = self.rec_handler.map_by_id(records)
801
+ for result in results:
802
+ records_by_id[result["RecordId"]].set_field_values(result)
803
+
804
+ def create_record_table_dialog(self,
805
+ title: str,
806
+ msg: str,
807
+ fields: Iterable[FieldValue] | DataTypeLayoutIdentifier,
808
+ wrapper_type: type[WrappedType] | str,
809
+ count: int | tuple[int, int],
810
+ *,
811
+ default_modifier: FieldModifier | None = None,
812
+ field_modifiers: dict[FieldIdentifier, FieldModifier] | None = None,
813
+ group_by: FieldIdentifier | None = None,
814
+ image_data: Iterable[bytes] | None = None,
815
+ require_input: bool = False,
816
+ repeat_message: str | None = "Please provide a value to continue.") \
817
+ -> list[WrappedType] | list[PyRecordModel]:
818
+ """
819
+ Create a table dialog where the user may input data into the fields of the table. The table is constructed from
820
+ a list of records that are created using the given record model wrapper. After the user submits this dialog,
821
+ the values that the user provided are used to update the created records.
822
+
823
+ Makes webservice calls to get the data type definition and fields of the given records if they weren't
824
+ previously cached.
825
+
826
+ :param title: The title of the dialog.
827
+ :param msg: The message to display in the dialog. This can be formatted using HTML elements.
828
+ :param fields: The names of the fields to display as columns in the table. These names must match field names on
829
+ the data type of the provided wrapper. Provided field names may also be extension fields of the form
830
+ [Extension Data Type Name].[Data Field Name]. This parameter may also be an identifier for a data type
831
+ layout from the data type of the provided records. If None, then the layout assigned to the current user's
832
+ group for this data type will be used.
833
+ :param wrapper_type: The record model wrapper or data type name of the records to be created and updated. If
834
+ a data type name is provided, the returned records will be PyRecordModels instead of WrappedRecordModels.
835
+ :param count: The number of records to create. If provided as a tuple of two integers, the user will first be
836
+ prompted to select an integer between the two values in the tuple.
837
+ :param default_modifier: A default field modifier that will be applied to the given fields. This can be used to
838
+ make field definitions from the system behave differently than their system values. If this value is None,
839
+ then a default field modifier is created that causes all specified fields to be both visible and not key
840
+ fields. (Key fields get displayed first before any non-key fields in tables, so the key field setting is
841
+ disabled by default in order to have the columns in the table respect the order of the fields as they are
842
+ provided to this function.)
843
+ :param field_modifiers: A mapping of data field name to field modifier for changes that should be applied to
844
+ the matching field. If a data field name is not present in the provided dict, or the provided dictionary is
845
+ None, then the default modifier will be used.
846
+ :param group_by: If provided, the created table dialog will be grouped by the field with this name by default.
847
+ The user may remove this grouping if they want to.
848
+ :param image_data: The bytes to the images that should be displayed in the rows of the table. Each element in
849
+ the image data list corresponds to the element at the same index in the records list.
850
+ :param require_input: If true and the user is prompted to input the number of records to create, the request
851
+ will be re-sent if the user submits the dialog without making a selection.
852
+ :param repeat_message: If require_input is true and a repeat_message is provided, then that message appears
853
+ as toaster text if the record count dialog is repeated.
854
+ :return: A list of the newly created records.
855
+ """
856
+ count: int = self.__prompt_for_count(count, wrapper_type, require_input, repeat_message)
857
+ if count <= 0:
858
+ return []
859
+ records: list[WrappedType] | list[PyRecordModel] = self.rec_handler.add_models(wrapper_type, count)
860
+ self.set_record_table_dialog(title, msg, fields, records,
861
+ default_modifier=default_modifier, field_modifiers=field_modifiers,
862
+ group_by=group_by, image_data=image_data)
863
+ return records
864
+
865
+ # FR-47314: Create record dialogs that adapt to become a form or table based on the size of the input.
866
+ def record_adaptive_dialog(self,
867
+ title: str,
868
+ msg: str,
869
+ fields: Iterable[FieldIdentifier | FieldFilterCriteria] | DataTypeLayoutIdentifier,
870
+ records: Collection[SapioRecord],
871
+ *,
872
+ default_modifier: FieldModifier | None = None,
873
+ field_modifiers: dict[FieldIdentifier, FieldModifier] | None = None,
874
+ column_positions: dict[str, tuple[int, int]] | None = None,
875
+ group_by: FieldIdentifier | None = None,
876
+ image_data: Iterable[bytes] | None = None) -> list[FieldMap]:
877
+ """
878
+ Create a dialog where the user may input data into the specified fields. The dialog is constructed from
879
+ a given list of records of a singular type.
880
+
881
+ The dialog created will adapt to the number of records. If there is only one record then a form dialog will be
882
+ created. Otherwise, a table dialog is created.
883
+
884
+ Makes webservice calls to get the data type definition and fields of the given records if they weren't
885
+ previously cached.
886
+
887
+ :param title: The title of the dialog.
888
+ :param msg: The message to display in the dialog. This can be formatted using HTML elements.
889
+ :param fields: The names of the fields to display in the dialog. These names must match field names on
890
+ the data type of the provided record. Provided field names may also be extension fields of the form
891
+ [Extension Data Type Name].[Data Field Name]. This parameter may also be an identifier for a data type
892
+ layout from the data type of the provided records. If None, then the layout assigned to the current user's
893
+ group for this data type will be used.
894
+ :param records: The records to display in the dialog.
895
+ :param default_modifier: A default field modifier that will be applied to the given fields. This can be used to
896
+ make field definitions from the system behave differently than their system values. If this value is None,
897
+ then a default field modifier is created that causes all specified fields to be both visible and not key
898
+ fields. (Key fields get displayed first before any non-key fields in tables, so the key field setting is
899
+ disabled by default in order to have the columns in the table respect the order of the fields as they are
900
+ provided to this function.)
901
+ :param field_modifiers: A mapping of data field name to field modifier for changes that should be applied to
902
+ the matching field. If a data field name is not present in the provided dict, or the provided dictionary is
903
+ None, then the default modifier will be used.
904
+ :param column_positions: If a tuple is provided for a field name, alters that field's column position and column
905
+ span. (Field order is still determined by the fields list.) Has no effect if the fields parameter provides
906
+ a data type layout. Only used if the adaptive dialog becomes a form.
907
+ :param group_by: If provided, the created table dialog will be grouped by the field with this name by default.
908
+ The user may remove this grouping if they want to. Only used if the adaptive dialog becomes a table.
909
+ :param image_data: The bytes to the images that should be displayed in the rows of the table. Each element in
910
+ the image data list corresponds to the element at the same index in the records list. Only used if the
911
+ adaptive dialog becomes a table.
912
+ :return: A list of dictionaries mapping the data field names of the given field definitions to the response
913
+ value from the user for that field for each row. Even if a form was displayed, the field values will still
914
+ be returned in a list.
915
+ """
916
+ count: int = len(records)
917
+ if not count:
918
+ raise SapioException("No records provided.")
919
+ if count == 1:
920
+ return [self.record_form_dialog(title, msg, fields, list(records)[0], column_positions,
921
+ default_modifier=default_modifier, field_modifiers=field_modifiers)]
922
+ return self.record_table_dialog(title, msg, fields, records,
923
+ default_modifier=default_modifier, field_modifiers=field_modifiers,
924
+ group_by=group_by, image_data=image_data)
925
+
926
+ def set_record_adaptive_dialog(self,
927
+ title: str,
928
+ msg: str,
929
+ fields: Iterable[FieldIdentifier | FieldFilterCriteria] | DataTypeLayoutIdentifier,
930
+ records: Collection[SapioRecord],
931
+ *,
932
+ default_modifier: FieldModifier | None = None,
933
+ field_modifiers: dict[FieldIdentifier, FieldModifier] | None = None,
934
+ column_positions: dict[str, tuple[int, int]] | None = None,
935
+ group_by: FieldIdentifier | None = None,
936
+ image_data: Iterable[bytes] | None = None) -> None:
937
+ """
938
+ Create a dialog where the user may input data into the fields of the dialog. The dialog is constructed from
939
+ a given list of records of a singular type. After the user submits this dialog, the values that the user
940
+ provided are used to update the provided records.
941
+
942
+ The dialog created will adapt to the number of records. If there is only one record then a form dialog will be
943
+ created. Otherwise, a table dialog is created.
944
+
945
+ Makes webservice calls to get the data type definition and fields of the given records if they weren't
946
+ previously cached.
947
+
948
+ :param title: The title of the dialog.
949
+ :param msg: The message to display in the dialog. This can be formatted using HTML elements.
950
+ :param fields: The names of the fields to display in the dialog. These names must match field names on
951
+ the data type of the provided record. Provided field names may also be extension fields of the form
952
+ [Extension Data Type Name].[Data Field Name]. This parameter may also be an identifier for a data type
953
+ layout from the data type of the provided records. If None, then the layout assigned to the current user's
954
+ group for this data type will be used.
955
+ :param records: The records to display in the dialog and update the values of.
956
+ :param default_modifier: A default field modifier that will be applied to the given fields. This can be used to
957
+ make field definitions from the system behave differently than their system values. If this value is None,
958
+ then a default field modifier is created that causes all specified fields to be both visible and not key
959
+ fields. (Key fields get displayed first before any non-key fields in tables, so the key field setting is
960
+ disabled by default in order to have the columns in the table respect the order of the fields as they are
961
+ provided to this function.)
962
+ :param field_modifiers: A mapping of data field name to field modifier for changes that should be applied to
963
+ the matching field. If a data field name is not present in the provided dict, or the provided dictionary is
964
+ None, then the default modifier will be used.
965
+ :param column_positions: If a tuple is provided for a field name, alters that field's column position and column
966
+ span. (Field order is still determined by the fields list.) Has no effect if the fields parameter provides
967
+ a data type layout. Only used if the adaptive dialog becomes a form.
968
+ :param group_by: If provided, the created table dialog will be grouped by the field with this name by default.
969
+ The user may remove this grouping if they want to. Only used if the adaptive dialog becomes a table.
970
+ :param image_data: The bytes to the images that should be displayed in the rows of the table. Each element in
971
+ the image data list corresponds to the element at the same index in the records list. Only used if the
972
+ adaptive dialog becomes a table.
973
+ """
974
+ count: int = len(records)
975
+ if not count:
976
+ raise SapioException("No records provided.")
977
+ if count == 1:
978
+ self.set_record_form_dialog(title, msg, fields, list(records)[0], column_positions,
979
+ default_modifier=default_modifier, field_modifiers=field_modifiers)
980
+ else:
981
+ self.set_record_table_dialog(title, msg, fields, records,
982
+ default_modifier=default_modifier, field_modifiers=field_modifiers,
983
+ group_by=group_by, image_data=image_data)
984
+
985
+ def create_record_adaptive_dialog(self,
986
+ title: str,
987
+ msg: str,
988
+ fields: Iterable[FieldValue] | DataTypeLayoutIdentifier,
989
+ wrapper_type: type[WrappedType] | str,
990
+ count: int | tuple[int, int],
991
+ *,
992
+ default_modifier: FieldModifier | None = None,
993
+ field_modifiers: dict[FieldIdentifier, FieldModifier] | None = None,
994
+ column_positions: dict[str, tuple[int, int]] | None = None,
995
+ group_by: FieldIdentifier | None = None,
996
+ image_data: Iterable[bytes] | None = None,
997
+ require_input: bool = False,
998
+ repeat_message: str | None = "Please provide a value to continue.") \
999
+ -> list[WrappedType]:
1000
+ """
1001
+ Create a dialog where the user may input data into the specified fields. The dialog is constructed from
1002
+ a list of records that are created using the given record model wrapper. After the user submits this dialog,
1003
+ the values that the user provided are used to update the created records.
1004
+
1005
+ The dialog created will adapt to the number of records. If there is only one record then a form dialog will be
1006
+ created. Otherwise, a table dialog is created.
1007
+
1008
+ Makes webservice calls to get the data type definition and fields of the given records if they weren't
1009
+ previously cached.
1010
+
1011
+ :param title: The title of the dialog.
1012
+ :param msg: The message to display in the dialog. This can be formatted using HTML elements.
1013
+ :param fields: The names of the fields to display in the dialog. These names must match field names on
1014
+ the data type of the provided wrapper. Provided field names may also be extension fields of the form
1015
+ [Extension Data Type Name].[Data Field Name]. This parameter may also be an identifier for a data type
1016
+ layout from the data type of the provided records. If None, then the layout assigned to the current user's
1017
+ group for this data type will be used.
1018
+ :param wrapper_type: The record model wrapper or data type name of the records to be created and updated. If
1019
+ a data type name is provided, the returned records will be PyRecordModels instead of WrappedRecordModels.
1020
+ :param count: The number of records to create. If provided as a tuple of two integers, the user will first be
1021
+ prompted to select an integer between the two values in the tuple.
1022
+ :param default_modifier: A default field modifier that will be applied to the given fields. This can be used to
1023
+ make field definitions from the system behave differently than their system values. If this value is None,
1024
+ then a default field modifier is created that causes all specified fields to be both visible and not key
1025
+ fields. (Key fields get displayed first before any non-key fields in tables, so the key field setting is
1026
+ disabled by default in order to have the columns in the table respect the order of the fields as they are
1027
+ provided to this function.)
1028
+ :param field_modifiers: A mapping of data field name to field modifier for changes that should be applied to
1029
+ the matching field. If a data field name is not present in the provided dict, or the provided dictionary is
1030
+ None, then the default modifier will be used.
1031
+ :param column_positions: If a tuple is provided for a field name, alters that field's column position and column
1032
+ span. (Field order is still determined by the fields list.) Has no effect if the fields parameter provides
1033
+ a data type layout. Only used if the adaptive dialog becomes a form.
1034
+ :param group_by: If provided, the created table dialog will be grouped by the field with this name by default.
1035
+ The user may remove this grouping if they want to. Only used if the adaptive dialog becomes a table.
1036
+ :param image_data: The bytes to the images that should be displayed in the rows of the table. Each element in
1037
+ the image data list corresponds to the element at the same index in the records list. Only used if the
1038
+ adaptive dialog becomes a table.
1039
+ :param require_input: If true and the user is prompted to input the number of records to create, the request
1040
+ will be re-sent if the user submits the dialog without making a selection.
1041
+ :param repeat_message: If require_input is true and a repeat_message is provided, then that message appears
1042
+ as toaster text if the record count dialog is repeated.
1043
+ :return: A list of the newly created records. Even if a form was displayed, the created record will still be
1044
+ returned in a list.
1045
+ """
1046
+ count: int = self.__prompt_for_count(count, wrapper_type, require_input, repeat_message)
1047
+ if count <= 0:
1048
+ return []
1049
+ if count == 1:
1050
+ return [self.create_record_form_dialog(title, msg, fields, wrapper_type, column_positions,
1051
+ default_modifier=default_modifier, field_modifiers=field_modifiers)]
1052
+ return self.create_record_table_dialog(title, msg, fields, wrapper_type, count,
1053
+ default_modifier=default_modifier, field_modifiers=field_modifiers,
1054
+ group_by=group_by, image_data=image_data)
1055
+
563
1056
  def multi_type_table_dialog(self,
564
1057
  title: str,
565
1058
  msg: str,
566
- fields: list[(str, str) | AbstractVeloxFieldDefinition],
567
- row_contents: list[list[SapioRecord | FieldMap]],
1059
+ fields: Iterable[tuple[DataTypeIdentifier, FieldIdentifier] | AbstractVeloxFieldDefinition],
1060
+ row_contents: Iterable[Iterable[SapioRecord | FieldMap]],
568
1061
  *,
569
1062
  default_modifier: FieldModifier | None = None,
570
- field_modifiers: dict[str, FieldModifier] | None = None,
571
- data_type: str = "Default",
1063
+ field_modifiers: dict[FieldIdentifier, FieldModifier] | None = None,
1064
+ data_type: DataTypeIdentifier = "Default",
572
1065
  display_name: str | None = None,
573
1066
  plural_display_name: str | None = None) -> list[FieldMap]:
574
1067
  """
@@ -581,7 +1074,7 @@ class CallbackUtil:
581
1074
  previously cached.
582
1075
 
583
1076
  :param title: The title of the dialog.
584
- :param msg: The message to display in the dialog.
1077
+ :param msg: The message to display in the dialog. This can be formatted using HTML elements.
585
1078
  :param fields: A list of objects representing the fields in the table. This could either be a two-element tuple
586
1079
  where the first element is a data type name and the second is a field name, or it could be a field
587
1080
  definition. If it is the former, a query will be made to find the field definition matching tht data type.
@@ -627,23 +1120,31 @@ class CallbackUtil:
627
1120
  :return: A list of dictionaries mapping the data field names of the given field definitions to the response
628
1121
  value from the user for that field for each row.
629
1122
  """
1123
+ if not row_contents:
1124
+ raise SapioException("No values provided.")
1125
+
630
1126
  # Set the default modifier to make all fields visible and not key if no default was provided.
631
1127
  if default_modifier is None:
632
1128
  default_modifier = FieldModifier(visible=True, key_field=False)
633
1129
  # To make things simpler, treat null field modifiers as an empty dict.
634
1130
  if field_modifiers is None:
635
1131
  field_modifiers = {}
1132
+ else:
1133
+ field_modifiers: dict[str, FieldModifier] = AliasUtil.to_data_field_names_dict(field_modifiers)
636
1134
 
637
1135
  # Construct the final fields list from the possible field objects.
638
1136
  final_fields: list[AbstractVeloxFieldDefinition] = []
639
1137
  # Keep track of whether any given field name appears more than once, as two fields could have the same
640
1138
  # field name but different data types. In this case, the user should provide a field modifier or field
641
1139
  # definition that changes one of the field names.
642
- field_names: list[str] = []
1140
+ raw_field_names: set[str] = set()
1141
+ field_names: set[str] = set()
643
1142
  for field in fields:
644
1143
  # Find the field definition for this field object.
645
1144
  if isinstance(field, tuple):
646
- field_def: AbstractVeloxFieldDefinition = self.dt_cache.get_fields_for_type(field[0]).get(field[1])
1145
+ dt: str = AliasUtil.to_data_type_name(field[0])
1146
+ fld: str = AliasUtil.to_data_field_name(field[1])
1147
+ field_def: AbstractVeloxFieldDefinition = self.__get_field_def(dt, fld)
647
1148
  elif isinstance(field, AbstractVeloxFieldDefinition):
648
1149
  field_def: AbstractVeloxFieldDefinition = field
649
1150
  else:
@@ -651,7 +1152,21 @@ class CallbackUtil:
651
1152
 
652
1153
  # Locate the modifier for this field and store the modified field.
653
1154
  name: str = field_def.data_field_name
654
- modifier: FieldModifier = field_modifiers.get(name, default_modifier)
1155
+ # PR-47378: Account for the scenario where two fields share the same field name and we need to determine
1156
+ # which field modifier to apply to each field name.
1157
+ duplicate: bool = name in raw_field_names
1158
+ if duplicate and name in field_modifiers:
1159
+ raise SapioException(f"The field name \"{name}\" appears more than once in the given fields while also "
1160
+ f"having a field_modifiers dictionary key of the same name. This function is "
1161
+ f"unable to distinguish which field the field modifier should be applied to. "
1162
+ f"Update your field_modifiers dictionary to provide keys in the form "
1163
+ f"[Data Type Name].[Data Field Name] for this field name.")
1164
+ raw_field_names.add(name)
1165
+ full_name = f"{field_def.data_type_name}.{name}"
1166
+ if full_name in field_modifiers:
1167
+ modifier: FieldModifier = field_modifiers.get(full_name)
1168
+ else:
1169
+ modifier: FieldModifier = field_modifiers.get(name, default_modifier)
655
1170
  field_def: AbstractVeloxFieldDefinition = modifier.modify_field(field_def)
656
1171
  final_fields.append(field_def)
657
1172
 
@@ -661,9 +1176,9 @@ class CallbackUtil:
661
1176
  if name in field_names:
662
1177
  raise SapioException(f"The field name \"{name}\" appears more than once in the given fields. "
663
1178
  f"If you have provided two fields with the same name but different data types, "
664
- f"consider providing a FieldModifier where prepend_data_type is true for one of "
665
- f"the fields so that the field names will be different.")
666
- field_names.append(name)
1179
+ f"consider providing a FieldModifier where prepend_data_type is true for "
1180
+ f"this field so that the field names will become different.")
1181
+ field_names.add(name)
667
1182
 
668
1183
  # Get the values for each row.
669
1184
  values: list[dict[str, FieldValue]] = []
@@ -678,7 +1193,7 @@ class CallbackUtil:
678
1193
  if rec is None:
679
1194
  continue
680
1195
  # Map records to their data type name. Map field maps to Default.
681
- dt: str = "Default" if isinstance(rec, dict) else rec.data_type_name
1196
+ dt: str = "Default" if isinstance(rec, dict) else AliasUtil.to_data_type_names(rec)
682
1197
  # Warn if the same data type name appears more than once.
683
1198
  if dt in row_records:
684
1199
  raise SapioException(f"The data type \"{dt}\" appears more than once in the given row contents.")
@@ -690,7 +1205,7 @@ class CallbackUtil:
690
1205
  record: SapioRecord | FieldMap | None = row_records.get(field.data_type_name)
691
1206
  # This could be either a record, a field map, or null. Convert any records to field maps.
692
1207
  if not isinstance(record, dict) and record is not None:
693
- record: FieldMap | None = AliasUtil.to_field_map_lists([record])[0]
1208
+ record: FieldMap | None = AliasUtil.to_field_map(record)
694
1209
 
695
1210
  # Find out if this field had its data type prepended to it. If this is the case, then we need to find
696
1211
  # the true data field name before retrieving the value from the field map.
@@ -702,35 +1217,22 @@ class CallbackUtil:
702
1217
  row_values[field.data_field_name] = record.get(name) if record else None
703
1218
  values.append(row_values)
704
1219
 
705
- if display_name is None:
706
- display_name = data_type
707
- if plural_display_name is None:
708
- plural_display_name = display_name + "s"
709
-
710
- builder = FormBuilder(data_type, display_name, plural_display_name)
711
- for field in final_fields:
712
- builder.add_field(field)
1220
+ # Build a temporary data type for the request.
1221
+ temp_dt = self.__temp_dt_from_field_defs(data_type, display_name, plural_display_name, final_fields, None)
713
1222
 
714
- request = TableEntryDialogRequest(title, msg, builder.get_temporary_data_type(), values,
1223
+ # Send the request to the user.
1224
+ request = TableEntryDialogRequest(title, msg, temp_dt, values,
715
1225
  width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
716
- try:
717
- self.user.timeout_seconds = self.timeout_seconds
718
- response: list[FieldMap] | None = self.callback.show_table_entry_dialog(request)
719
- except ReadTimeout:
720
- raise SapioDialogTimeoutException()
721
- finally:
722
- self.user.timeout_seconds = self._original_timeout
723
- if response is None:
724
- raise SapioUserCancelledException()
1226
+ response: list[FieldMap] = self.__handle_dialog_request(request, self.callback.show_table_entry_dialog)
725
1227
  return response
726
-
1228
+
727
1229
  def record_view_dialog(self,
728
1230
  title: str,
729
1231
  record: SapioRecord,
730
- layout: str | DataTypeLayout | None = None,
1232
+ layout: DataTypeLayoutIdentifier = None,
731
1233
  minimized: bool = False,
732
1234
  access_level: FormAccessLevel | None = None,
733
- plugin_path_list: list[str] | None = None) -> None:
1235
+ plugin_path_list: Iterable[str] | None = None) -> None:
734
1236
  """
735
1237
  Create an IDV dialog for the given record. This IDV may use an existing layout already defined in the system,
736
1238
  and can be created to allow the user to edit the field in the IDV, or to be read-only for the user to review.
@@ -751,163 +1253,219 @@ class CallbackUtil:
751
1253
  :param plugin_path_list: A white list of plugins that should be displayed in the dialog. This white list
752
1254
  includes plugins that would be displayed on sub-tables/components in the layout.
753
1255
  """
754
- # Ensure that the given record is a DataRecord.
1256
+ # Get the data record and data type layout from the provided parameters.
755
1257
  record: DataRecord = AliasUtil.to_data_record(record)
1258
+ layout: DataTypeLayout | None = self.__to_layout(AliasUtil.to_data_type_name(record), layout)
756
1259
 
757
- # Get the corresponding DataTypeLayout for the provided name.
758
- if isinstance(layout, str):
759
- # TODO: Replace with dt_cache if the DataTypeCacheManager ever starts caching layouts.
760
- dt_man = DataMgmtServer.get_data_type_manager(self.user)
761
- data_type: str = record.get_data_type_name()
762
- layouts: dict[str, DataTypeLayout] = {x.layout_name: x for x in dt_man.get_data_type_layout_list(data_type)}
763
- layout_name: str = layout
764
- layout: DataTypeLayout | None = layouts.get(layout_name)
765
- # If a name was provided then the caller expects that name to exist. Throw an exception if it doesn't.
766
- if not layout:
767
- raise SapioException(f"The data type \"{data_type}\" does not have a layout by the name "
768
- f"\"{layout_name}\" in the system.")
769
-
1260
+ # Send the request to the user.
770
1261
  request = DataRecordDialogRequest(title, record, layout, minimized, access_level, plugin_path_list,
771
1262
  width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
772
- try:
773
- self.user.timeout_seconds = self.timeout_seconds
774
- response: bool = self.callback.data_record_form_view_dialog(request)
775
- except ReadTimeout:
776
- raise SapioDialogTimeoutException()
777
- finally:
778
- self.user.timeout_seconds = self._original_timeout
1263
+ response: bool = self.__handle_dialog_request(request, self.callback.data_record_form_view_dialog)
1264
+ # The __handle_dialog_request function only throws a cancelled exception if the response is None, but in
1265
+ # this case we also want to throw if the response is False.
779
1266
  if not response:
780
1267
  raise SapioUserCancelledException()
781
-
1268
+
1269
+ # CR-47326: Allow the selection dialog functions to preselect rows/records in the table.
782
1270
  def selection_dialog(self,
783
1271
  msg: str,
784
- fields: list[AbstractVeloxFieldDefinition],
785
- values: list[FieldMap],
1272
+ fields: Iterable[AbstractVeloxFieldDefinition],
1273
+ values: Iterable[FieldMap],
786
1274
  multi_select: bool = True,
1275
+ preselected_rows: Iterable[FieldMap | RecordIdentifier] | None = None,
787
1276
  *,
788
- data_type: str = "Default",
1277
+ data_type: DataTypeIdentifier = "Default",
789
1278
  display_name: str | None = None,
790
- plural_display_name: str | None = None) -> list[FieldMap]:
1279
+ plural_display_name: str | None = None,
1280
+ image_data: Iterable[bytes] | None = None,
1281
+ require_selection: bool = False,
1282
+ repeat_message: str | None = "Please provide a selection to continue.") -> list[FieldMap]:
791
1283
  """
792
1284
  Create a selection dialog for a list of field maps for the user to choose from. Requires that the caller
793
1285
  provide the definitions of every field in the table.
1286
+ The title of a selection dialog will always be "Select [plural display name]".
794
1287
 
795
- :param msg: The message to display in the dialog.
1288
+ :param msg: The message to display in the dialog. This can be formatted using HTML elements.
796
1289
  :param fields: The definitions of the fields to display as table columns. Fields will be displayed in the order
797
1290
  they are provided in this list.
798
1291
  :param values: The values to set for each row of the table.
799
1292
  :param multi_select: Whether the user is able to select multiple rows from the list.
1293
+ :param preselected_rows: The rows that should be selected in the dialog when it is initially
1294
+ displayed to the user. The user will be allowed to deselect these records if they so wish. If preselected
1295
+ rows are provided, the dialog will automatically allow multi-selection of records. Note that in order for
1296
+ preselected rows to be identified, they MUST contain a "RecordId" field with a numeric value that is unique
1297
+ across all provided values.
800
1298
  :param data_type: The data type name for the temporary data type that will be created for this table.
801
1299
  :param display_name: The display name for the temporary data type. If not provided, defaults to the data type
802
1300
  name.
803
1301
  :param plural_display_name: The plural display name for the temporary data type. If not provided, defaults to
804
1302
  the display name + "s".
1303
+ :param image_data: The bytes to the images that should be displayed in the rows of the table. Each element in
1304
+ the image data list corresponds to the element at the same index in the values list.
1305
+ :param require_selection: If true, the request will be re-sent if the user submits the dialog without making
1306
+ a selection.
1307
+ :param repeat_message: If require_selection is true and a repeat_message is provided, then that message appears
1308
+ as toaster text if the dialog is repeated.
805
1309
  :return: A list of field maps corresponding to the chosen input field maps.
806
1310
  """
807
- if display_name is None:
808
- display_name = data_type
809
- if plural_display_name is None:
810
- plural_display_name = display_name + "s"
811
-
812
- builder = FormBuilder(data_type, display_name, plural_display_name)
813
- for field in fields:
814
- builder.add_field(field)
815
-
816
- request = TempTableSelectionRequest(builder.get_temporary_data_type(), msg, values,
817
- multi_select=multi_select)
818
- try:
819
- self.user.timeout_seconds = self.timeout_seconds
820
- response: list[FieldMap] | None = self.callback.show_temp_table_selection_dialog(request)
821
- except ReadTimeout:
822
- raise SapioDialogTimeoutException()
823
- finally:
824
- self.user.timeout_seconds = self._original_timeout
825
- if response is None:
826
- raise SapioUserCancelledException()
1311
+ if not values:
1312
+ raise SapioException("No values provided.")
1313
+
1314
+ if preselected_rows:
1315
+ # Confirm that the provided field maps are validly configured to allow the use of preselected rows.
1316
+ encountered_ids: set[int] = set()
1317
+ for row in values:
1318
+ if "RecordId" not in row or row["RecordId"] is None:
1319
+ raise SapioException("When using preselected_rows, the provided field map values must have a "
1320
+ "RecordId field.")
1321
+ row_id: int = row["RecordId"]
1322
+ if row_id in encountered_ids:
1323
+ raise SapioException(f"Not all RecordId values in the provided field maps are unique. "
1324
+ f"{row_id} was encountered more than once.")
1325
+ encountered_ids.add(row_id)
1326
+
1327
+ # Convert the preselected rows to a list of integers.
1328
+ new_list: list[int] = []
1329
+ for value in preselected_rows:
1330
+ if isinstance(value, dict):
1331
+ new_list.append(value["RecordId"])
1332
+ else:
1333
+ new_list.append(AliasUtil.to_record_id(value))
1334
+ preselected_rows: list[int] = new_list
1335
+
1336
+ # Add a RecordId definition to the fields if one is not already present. This is necessary for the
1337
+ # pre-selected records parameter to function.
1338
+ fields = list(fields)
1339
+ if "RecordId" not in [x.data_field_name for x in fields]:
1340
+ builder = FieldBuilder(data_type)
1341
+ fields.append(builder.long_field("RecordId", abstract_info=AnyFieldInfo(visible=False)))
1342
+
1343
+ # Build a temporary data type for the request.
1344
+ temp_dt = self.__temp_dt_from_field_defs(data_type, display_name, plural_display_name, fields, None)
1345
+ temp_dt.record_image_assignable = bool(image_data)
1346
+
1347
+ # Send the request to the user.
1348
+ request = TempTableSelectionRequest(temp_dt, msg, list(values), image_data, preselected_rows, multi_select)
1349
+ # If require_selection is true, repeat the request if the user didn't make a selection.
1350
+ while True:
1351
+ response: list[FieldMap] = self.__handle_dialog_request(request,
1352
+ self.callback.show_temp_table_selection_dialog)
1353
+ if not require_selection or response:
1354
+ break
1355
+ if repeat_message:
1356
+ self.toaster_popup(repeat_message, popup_type=PopupType.Warning)
827
1357
  return response
828
-
829
- def record_selection_dialog(self, msg: str, fields: list[str], records: list[SapioRecord],
830
- multi_select: bool = True) -> list[SapioRecord]:
1358
+
1359
+ def record_selection_dialog(self,
1360
+ msg: str,
1361
+ fields: Iterable[FieldIdentifier | FieldFilterCriteria] | DataTypeLayoutIdentifier,
1362
+ records: Iterable[SapioRecord],
1363
+ multi_select: bool = True,
1364
+ preselected_records: Iterable[RecordIdentifier] | None = None,
1365
+ *,
1366
+ image_data: Iterable[bytes] | None = None,
1367
+ require_selection: bool = False,
1368
+ repeat_message: str | None = "Please provide a selection to continue.") \
1369
+ -> list[SapioRecord]:
831
1370
  """
832
1371
  Create a record selection dialog for a list of records for the user to choose from. Provided field names must
833
1372
  match fields on the definition of the data type of the given records.
1373
+ The title of a selection dialog will always be "Select [plural display name]".
834
1374
 
835
1375
  Makes webservice calls to get the data type definition and fields of the given records if they weren't
836
1376
  previously cached.
837
1377
 
838
- :param msg: The message to display in the dialog.
1378
+ :param msg: The message to display in the dialog. This can be formatted using HTML elements.
839
1379
  :param fields: The names of the fields to display as columns in the table. Fields will be displayed in the order
840
- they are provided in this list.
1380
+ they are provided in this list. This parameter may also be an identifier for a data type layout from the
1381
+ data type of the provided records. If None, then the layout assigned to the current user's group for this
1382
+ data type will be used.
841
1383
  :param records: The records to display as rows in the table.
842
1384
  :param multi_select: Whether the user is able to select multiple records from the list.
1385
+ :param preselected_records: The records that should be selected in the dialog when it is initially
1386
+ displayed to the user. The user will be allowed to deselect these records if they so wish. If preselected
1387
+ record IDs are provided, the dialog will automatically allow multi-selection of records.
1388
+ :param image_data: The bytes to the images that should be displayed in the rows of the table. Each element in
1389
+ the image data list corresponds to the element at the same index in the records list.
1390
+ :param require_selection: If true, the request will be re-sent if the user submits the dialog without making
1391
+ a selection.
1392
+ :param repeat_message: If require_selection is true and a repeat_message is provided, then that message appears
1393
+ as toaster text if the dialog is repeated.
843
1394
  :return: A list of the selected records.
844
1395
  """
1396
+ # Get the data type name and field values from the provided records.
845
1397
  if not records:
846
1398
  raise SapioException("No records provided.")
847
- data_types: set[str] = {x.data_type_name for x in records}
848
- if len(data_types) > 1:
849
- raise SapioException("Multiple data type names encountered in records list for record table popup.")
850
- data_type: str = data_types.pop()
851
- # Get the field maps from the records.
852
- field_map_list: list[FieldMap] = AliasUtil.to_field_map_lists(records)
853
- # Put the record ID of each record in its corresponding field map so that we can map the field maps back to
854
- # the records when we return them to the caller.
855
- for record, field_map in zip(records, field_map_list):
856
- field_map.update({"RecId": record.record_id})
857
- # Get the field definitions of the data type.
858
- type_def: DataTypeDefinition = self.dt_cache.get_data_type(data_type)
859
- field_defs: dict[str, AbstractVeloxFieldDefinition] = self.dt_cache.get_fields_for_type(data_type)
1399
+ data_type: str = AliasUtil.to_singular_data_type_name(records)
1400
+ field_map_list: list[FieldMap] = AliasUtil.to_field_map_list(records, include_record_id=True)
860
1401
 
861
1402
  # Key fields display their columns in order before all non-key fields.
862
1403
  # Unmark key fields so that the column order is respected exactly as the caller provides it.
863
1404
  # Also make everything visible, because presumably the caller give a field name because they want it to be seen.
864
1405
  modifier = FieldModifier(visible=True, key_field=False)
865
1406
 
866
- # Build the form using only those fields that are desired.
867
- builder = FormBuilder(data_type, type_def.display_name, type_def.plural_display_name)
868
- for field_name in fields:
869
- field_def = field_defs.get(field_name)
870
- if field_def is None:
871
- raise SapioException(f"No field of name \"{field_name}\" in field definitions of type \"{data_type}\"")
872
- builder.add_field(modifier.modify_field(field_def))
1407
+ # Build a temporary data type for the request.
1408
+ if isinstance(fields, DataTypeLayoutIdentifier):
1409
+ temp_dt = self.__temp_dt_from_layout(data_type, fields, modifier, {})
1410
+ else:
1411
+ temp_dt = self.__temp_dt_from_field_names(data_type, fields, None, modifier, {})
1412
+ temp_dt.record_image_assignable = bool(image_data)
1413
+
1414
+ if preselected_records:
1415
+ # Convert the preselected records to a list of integers.
1416
+ preselected_records: list[int] = AliasUtil.to_record_ids(preselected_records)
1417
+ # Add a RecordId definition to the fields if one is not already present. This is necessary for the
1418
+ # pre-selected records parameter to function.
1419
+ if "RecordId" not in [x.data_field_name for x in temp_dt.get_field_def_list()]:
1420
+ builder = FieldBuilder(data_type)
1421
+ temp_dt.set_field_definition(builder.long_field("RecordId", abstract_info=AnyFieldInfo(visible=False)))
1422
+
1423
+ # Send the request to the user.
1424
+ request = TempTableSelectionRequest(temp_dt, msg, field_map_list, image_data, preselected_records, multi_select)
1425
+ # If require_selection is true, repeat the request if the user didn't make a selection.
1426
+ while True:
1427
+ response: list[FieldMap] = self.__handle_dialog_request(request,
1428
+ self.callback.show_temp_table_selection_dialog)
1429
+ if not require_selection or response:
1430
+ break
1431
+ if repeat_message:
1432
+ self.toaster_popup(repeat_message, popup_type=PopupType.Warning)
873
1433
 
874
- request = TempTableSelectionRequest(builder.get_temporary_data_type(), msg, field_map_list,
875
- multi_select=multi_select)
876
- try:
877
- self.user.timeout_seconds = self.timeout_seconds
878
- response: list[FieldMap] | None = self.callback.show_temp_table_selection_dialog(request)
879
- except ReadTimeout:
880
- raise SapioDialogTimeoutException()
881
- finally:
882
- self.user.timeout_seconds = self._original_timeout
883
- if response is None:
884
- raise SapioUserCancelledException()
885
1434
  # Map the field maps in the response back to the record they come from, returning the chosen record instead of
886
1435
  # the chosen field map.
887
1436
  records_by_id: dict[int, SapioRecord] = RecordHandler.map_by_id(records)
888
1437
  ret_list: list[SapioRecord] = []
889
1438
  for field_map in response:
890
- ret_list.append(records_by_id.get(field_map.get("RecId")))
1439
+ ret_list.append(records_by_id.get(field_map.get("RecordId")))
891
1440
  return ret_list
892
1441
 
1442
+ # CR-47377: Add allow_creation and default_creation_number to cover new parameters of this request type from 24.12.
893
1443
  def input_selection_dialog(self,
894
- wrapper_type: type[WrappedType],
1444
+ wrapper_type: type[WrappedType] | str,
895
1445
  msg: str,
896
1446
  multi_select: bool = True,
897
1447
  only_key_fields: bool = False,
898
- search_types: list[SearchType] | None = None,
1448
+ search_types: Iterable[SearchType] | None = None,
899
1449
  scan_criteria: ScanToSelectCriteria | None = None,
900
1450
  custom_search: CustomReport | CustomReportCriteria | str | None = None,
901
- preselected_records: list[RecordIdentifier] | None = None,
902
- record_blacklist: list[RecordIdentifier] | None = None,
903
- record_whitelist: list[RecordIdentifier] | None = None) -> list[WrappedType]:
1451
+ preselected_records: Iterable[RecordIdentifier] | None = None,
1452
+ record_blacklist: Iterable[RecordIdentifier] | None = None,
1453
+ record_whitelist: Iterable[RecordIdentifier] | None = None,
1454
+ allow_creation: bool = False,
1455
+ default_creation_number: int = 1,
1456
+ *,
1457
+ require_selection: bool = False,
1458
+ repeat_message: str | None = "Please provide a selection to continue.") \
1459
+ -> list[WrappedType] | list[PyRecordModel]:
904
1460
  """
905
1461
  Display a table of records that exist in the system matching the given data type and filter criteria and have
906
1462
  the user select one or more records from the table.
1463
+ The title of a selection dialog will always be "Select [plural display name]".
907
1464
 
908
- :param wrapper_type: The record model wrapper for the records to display in the dialog.
1465
+ :param wrapper_type: The record model wrapper or data type name for the records to display in the dialog. If
1466
+ a data type name is provided, the returned records will be PyRecordModels instead of WrappedRecordModels.
909
1467
  :param msg: The message to show near the top of the dialog, below the title. This can be used to
910
- instruct the user on what is desired from the dialog.
1468
+ instruct the user on what is desired from the dialog. This can be formatted using HTML elements.
911
1469
  :param multi_select: Whether the user may select multiple items at once in this dialog.
912
1470
  :param only_key_fields: Whether only key fields of the selected data type should be displayed in the table
913
1471
  of data in the dialog. If false, allows all possible fields to be displayed as columns in the table.
@@ -931,10 +1489,22 @@ class CallbackUtil:
931
1489
  :param record_blacklist: A list of records that should not be seen as possible options in the dialog.
932
1490
  :param record_whitelist: A list of records that will be seen as possible options in the dialog. Records not in
933
1491
  this whitelist will not be displayed if a whitelist is provided.
1492
+ :param allow_creation: Whether the "Create New" button will be visible to the user to create new records of the
1493
+ given type. The user must also have group access to be able to create the records.
1494
+ :param default_creation_number: If the user clicks the "Create New" button, then this is the value that will
1495
+ appear by default in the dialog that prompts the user to select how many new records to create. The value
1496
+ must be between 1 and 500, with values outside of that range being clamped to it. If this value is greater
1497
+ than 1, then multi-selection must be true. The data type definition of the records being created must have
1498
+ "Prompt for Number to Add" set to true in order to allow the user to select how many records to create, as
1499
+ otherwise user will only ever be able to create one record at a time.
1500
+ :param require_selection: If true, the request will be re-sent if the user submits the dialog without making
1501
+ a selection.
1502
+ :param repeat_message: If require_selection is true and a repeat_message is provided, then that message appears
1503
+ as toaster text if the dialog is repeated.
934
1504
  :return: A list of the records selected by the user in the dialog, wrapped as record models using the provided
935
1505
  wrapper.
936
1506
  """
937
- data_type: str = wrapper_type.get_wrapper_data_type_name()
1507
+ data_type: str = AliasUtil.to_data_type_name(wrapper_type)
938
1508
 
939
1509
  # Reduce the provided lists of records down to lists of record IDs.
940
1510
  if preselected_records:
@@ -952,53 +1522,64 @@ class CallbackUtil:
952
1522
  if isinstance(custom_search, str):
953
1523
  custom_search: CustomReport = CustomReportUtil.get_system_report_criteria(self.user, custom_search)
954
1524
 
1525
+ # Send the request to the user.
955
1526
  request = InputSelectionRequest(data_type, msg, search_types, only_key_fields, record_blacklist,
956
1527
  record_whitelist, preselected_records, custom_search, scan_criteria,
957
- multi_select)
958
- try:
959
- self.user.timeout_seconds = self.timeout_seconds
960
- response: list[DataRecord] | None = self.callback.show_input_selection_dialog(request)
961
- except ReadTimeout:
962
- raise SapioDialogTimeoutException()
963
- finally:
964
- self.user.timeout_seconds = self._original_timeout
965
- if response is None:
966
- raise SapioUserCancelledException()
967
- return RecordHandler(self.user).wrap_models(response, wrapper_type)
968
-
969
- def esign_dialog(self, title: str, msg: str, show_comment: bool = True,
970
- additional_fields: list[AbstractVeloxFieldDefinition] = None) -> ESigningResponsePojo:
1528
+ multi_select, allow_creation, default_creation_number)
1529
+ # If require_selection is true, repeat the request if the user didn't make a selection.
1530
+ while True:
1531
+ response: list[DataRecord] = self.__handle_dialog_request(request,
1532
+ self.callback.show_input_selection_dialog)
1533
+ if not require_selection or response:
1534
+ break
1535
+ if repeat_message:
1536
+ self.toaster_popup(repeat_message, popup_type=PopupType.Warning)
1537
+ return self.rec_handler.wrap_models(response, wrapper_type)
1538
+
1539
+ def esign_dialog(self,
1540
+ title: str,
1541
+ msg: str,
1542
+ show_comment: bool = True,
1543
+ additional_fields: Iterable[AbstractVeloxFieldDefinition] | None = None,
1544
+ *,
1545
+ require_authentication: bool = False) -> ESigningResponsePojo:
971
1546
  """
972
1547
  Create an e-sign dialog for the user to interact with.
973
-
1548
+
974
1549
  :param title: The title of the dialog.
975
- :param msg: The message to display in the dialog.
1550
+ :param msg: The message to display in the dialog. This can be formatted using HTML elements.
976
1551
  :param show_comment: Whether the "Meaning of Action" field should appear in the e-sign dialog. If true, the
977
1552
  user is required to provide an action.
978
1553
  :param additional_fields: Field definitions for additional fields to display in the dialog, for if there is
979
1554
  other information you wish to gather from the user alongside the e-sign.
1555
+ :param require_authentication: If true, the request will be re-sent if the user submits the dialog with invalid
1556
+ credentials.
980
1557
  :return: An e-sign response object containing information about the e-sign attempt.
981
1558
  """
1559
+ # Construct a temporary data type if any additional fields are provided.
982
1560
  temp_dt = None
983
1561
  if additional_fields:
984
1562
  builder = FormBuilder()
985
1563
  for field in additional_fields:
986
1564
  builder.add_field(field)
987
1565
  temp_dt = builder.get_temporary_data_type()
1566
+
1567
+ # Send the request to the user.
988
1568
  request = ESigningRequestPojo(title, msg, show_comment, temp_dt,
989
1569
  width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
990
- try:
991
- self.user.timeout_seconds = self.timeout_seconds
992
- response: ESigningResponsePojo | None = self.callback.show_esign_dialog(request)
993
- except ReadTimeout:
994
- raise SapioDialogTimeoutException()
995
- finally:
996
- self.user.timeout_seconds = self._original_timeout
997
- if response is None:
998
- raise SapioUserCancelledException()
1570
+ # If require_authentication is true, repeat the request if the user didn't provide valid credentials.
1571
+ while True:
1572
+ response: ESigningResponsePojo = self.__handle_dialog_request(request, self.callback.show_esign_dialog)
1573
+ if not require_authentication or response.authenticated:
1574
+ break
1575
+ # This matches the OOB behavior.
1576
+ self.toaster_popup("Incorrect username/password", popup_type=PopupType.Error)
1577
+ if not response.same_user:
1578
+ self.toaster_popup(f"This action requires the credentials of {self.user.username}",
1579
+ popup_type=PopupType.Error)
999
1580
  return response
1000
1581
 
1001
- def request_file(self, title: str, exts: list[str] | None = None,
1582
+ def request_file(self, title: str, exts: Iterable[str] | None = None,
1002
1583
  show_image_editor: bool = False, show_camera_button: bool = False) -> tuple[str, bytes]:
1003
1584
  """
1004
1585
  Request a single file from the user.
@@ -1023,21 +1604,15 @@ class CallbackUtil:
1023
1604
  def do_consume(chunk: bytes) -> None:
1024
1605
  return sink.consume_data(chunk, io_obj)
1025
1606
 
1607
+ # Send the request to the user.
1026
1608
  request = FilePromptRequest(title, show_image_editor, ",".join(exts), show_camera_button)
1027
- try:
1028
- self.user.timeout_seconds = self.timeout_seconds
1029
- file_path: str | None = self.callback.show_file_dialog(request, do_consume)
1030
- except ReadTimeout:
1031
- raise SapioDialogTimeoutException()
1032
- finally:
1033
- self.user.timeout_seconds = self._original_timeout
1034
- if file_path is None:
1035
- raise SapioUserCancelledException()
1609
+ file_path: str = self.__handle_dialog_request(request, self.callback.show_file_dialog, data_sink=do_consume)
1036
1610
 
1611
+ # Verify that each of the file given matches the expected extension(s).
1037
1612
  self.__verify_file(file_path, sink.data, exts)
1038
1613
  return file_path, sink.data
1039
1614
 
1040
- def request_files(self, title: str, exts: list[str] | None = None,
1615
+ def request_files(self, title: str, exts: Iterable[str] | None = None,
1041
1616
  show_image_editor: bool = False, show_camera_button: bool = False) -> dict[str, bytes]:
1042
1617
  """
1043
1618
  Request multiple files from the user.
@@ -1054,17 +1629,11 @@ class CallbackUtil:
1054
1629
  if exts is None:
1055
1630
  exts: list[str] = []
1056
1631
 
1632
+ # Send the request to the user.
1057
1633
  request = MultiFilePromptRequest(title, show_image_editor, ",".join(exts), show_camera_button)
1058
- try:
1059
- self.user.timeout_seconds = self.timeout_seconds
1060
- file_paths: list[str] | None = self.callback.show_multi_file_dialog(request)
1061
- except ReadTimeout:
1062
- raise SapioDialogTimeoutException()
1063
- finally:
1064
- self.user.timeout_seconds = self._original_timeout
1065
- if not file_paths:
1066
- raise SapioUserCancelledException()
1634
+ file_paths: list[str] = self.__handle_dialog_request(request, self.callback.show_multi_file_dialog)
1067
1635
 
1636
+ # Verify that each of the files given match the expected extension(s).
1068
1637
  ret_dict: dict[str, bytes] = {}
1069
1638
  for file_path in file_paths:
1070
1639
  sink = InMemoryRecordDataSink(self.user)
@@ -1075,7 +1644,7 @@ class CallbackUtil:
1075
1644
  return ret_dict
1076
1645
 
1077
1646
  @staticmethod
1078
- def __verify_file(file_path: str, file_bytes: bytes, allowed_extensions: list[str]) -> None:
1647
+ def __verify_file(file_path: str, file_bytes: bytes, allowed_extensions: Iterable[str]) -> None:
1079
1648
  """
1080
1649
  Verify that the provided file was read (i.e. the file path and file bytes aren't None or empty) and that it
1081
1650
  has the correct file extension. Raises a user error exception if something about the file is incorrect.
@@ -1086,7 +1655,7 @@ class CallbackUtil:
1086
1655
  """
1087
1656
  if file_path is None or len(file_path) == 0 or file_bytes is None or len(file_bytes) == 0:
1088
1657
  raise SapioUserErrorException("Empty file provided or file unable to be read.")
1089
- if len(allowed_extensions) != 0:
1658
+ if allowed_extensions:
1090
1659
  matches: bool = False
1091
1660
  for ext in allowed_extensions:
1092
1661
  if file_path.endswith("." + ext.lstrip(".")):
@@ -1103,8 +1672,8 @@ class CallbackUtil:
1103
1672
  :param file_name: The name of the file.
1104
1673
  :param file_data: The data of the file, provided as either a string or as a bytes array.
1105
1674
  """
1106
- data = io.BytesIO(file_data.encode() if isinstance(file_data, str) else file_data)
1107
- self.callback.send_file(file_name, False, data)
1675
+ with io.BytesIO(file_data.encode() if isinstance(file_data, str) else file_data) as data:
1676
+ self.callback.send_file(file_name, False, data)
1108
1677
 
1109
1678
  def write_zip_file(self, zip_name: str, files: dict[str, str | bytes]) -> None:
1110
1679
  """
@@ -1113,8 +1682,239 @@ class CallbackUtil:
1113
1682
  :param zip_name: The name of the zip file.
1114
1683
  :param files: A dictionary of the files to add to the zip file.
1115
1684
  """
1116
- data = io.BytesIO(FileUtil.zip_files(files))
1117
- self.callback.send_file(zip_name, False, data)
1685
+ with io.BytesIO(FileUtil.zip_files(files)) as data:
1686
+ self.callback.send_file(zip_name, False, data)
1687
+
1688
+ @staticmethod
1689
+ def __temp_dt_from_field_defs(data_type: DataTypeIdentifier, display_name: str | None,
1690
+ plural_display_name: str | None, fields: Iterable[AbstractVeloxFieldDefinition],
1691
+ column_positions: dict[str, tuple[int, int]] | None) -> TemporaryDataType:
1692
+ """
1693
+ Construct a Temporary Data Type definition from a provided list of field definitions for use in a callback.
1694
+ """
1695
+ # Get the data type name as a string from the parameters, and set the display name and plural display name if
1696
+ # they haven't been set.
1697
+ data_type: str = AliasUtil.to_data_type_name(data_type)
1698
+ if display_name is None:
1699
+ display_name = data_type
1700
+ if plural_display_name is None:
1701
+ plural_display_name = display_name + "s"
1702
+
1703
+ # Key fields display their columns in order before all non-key fields.
1704
+ # Unmark key fields so that the column order is respected exactly as the caller provides it.
1705
+ modifier = FieldModifier(key_field=False)
1706
+
1707
+ builder = FormBuilder(data_type, display_name, plural_display_name)
1708
+ for field_def in fields:
1709
+ # Determine the column and span for each field in the form.
1710
+ # If this isn't a form dialog, then adding the column and span to the FormBuilder has no effect.
1711
+ field_name = field_def.data_field_name
1712
+ column: int = 0
1713
+ span: int = 4
1714
+ if column_positions and field_name in column_positions:
1715
+ position = column_positions.get(field_name)
1716
+ column = position[0]
1717
+ span = position[1]
1718
+ # Apply the field modifier to each key field in the form.
1719
+ if field_def.key_field:
1720
+ field_def = modifier.modify_field(field_def)
1721
+ builder.add_field(field_def, column, span)
1722
+ return builder.get_temporary_data_type()
1723
+
1724
+ def __temp_dt_from_field_names(self, data_type: str, fields: Iterable[FieldIdentifier | FieldFilterCriteria],
1725
+ column_positions: dict[str, tuple[int, int]] | None,
1726
+ default_modifier: FieldModifier, field_modifiers: dict[str, FieldModifier]) \
1727
+ -> TemporaryDataType:
1728
+ """
1729
+ Construct a Temporary Data Type definition from a given data type name and list of field identifiers for that
1730
+ data type. Queries for the data type's definition to get the display name and plural display name, as well as
1731
+ the data field definitions of the data type to map the given field identifiers to field definitions. If an
1732
+ extension field is provided, then the extension data type's fields will be queried. Finally, applies the
1733
+ provided field modifiers to the field definitions to alter them from their system-set values
1734
+ """
1735
+ # Get the definition of the data type to construct the form builder with the proper values.
1736
+ type_def: DataTypeDefinition = self.dt_cache.get_data_type(data_type)
1737
+ builder = FormBuilder(data_type, type_def.display_name, type_def.plural_display_name)
1738
+
1739
+ # Determine if any FieldFilterCriteria were provided. If so, remove them from the fields list so that it
1740
+ # contains only field identifiers.
1741
+ fields = list(fields)
1742
+ filter_criteria: list[FieldFilterCriteria] = [x for x in fields if isinstance(x, FieldFilterCriteria)]
1743
+ for criteria in filter_criteria:
1744
+ fields.remove(criteria)
1745
+
1746
+ # Build the form using only those fields that are desired.
1747
+ field_names: list[str] = AliasUtil.to_data_field_names(fields)
1748
+ for field_name in field_names:
1749
+ field_def: AbstractVeloxFieldDefinition = self.__get_field_def(data_type, field_name)
1750
+
1751
+ # Determine the column and span for each field in the form.
1752
+ # If this isn't a form dialog, then adding the column and span to the FormBuilder has no effect.
1753
+ column: int = 0
1754
+ span: int = 4
1755
+ if column_positions and field_name in column_positions:
1756
+ position = column_positions.get(field_name)
1757
+ column = position[0]
1758
+ span = position[1]
1759
+
1760
+ # Apply the field modifiers to each field in the form.
1761
+ modifier: FieldModifier = field_modifiers.get(field_name, default_modifier)
1762
+ builder.add_field(modifier.modify_field(field_def), column, span)
1763
+
1764
+ # Now determine if any fields match the provided filter criteria.
1765
+ all_fields: dict[str, AbstractVeloxFieldDefinition] = self.dt_cache.get_fields_for_type(data_type)
1766
+ current_column: int = 0
1767
+ for criteria in filter_criteria:
1768
+ for field_name, field_def in all_fields.items():
1769
+ # Don't add fields that have already been added.
1770
+ if field_name in field_names or not criteria.field_matches(field_def):
1771
+ continue
1772
+ field_names.append(field_name)
1773
+
1774
+ # The caller can't know what fields are present, so the column positions dictionary can't be used.
1775
+ # Still come up with spans for each field to minimize wasted space.
1776
+ # Give boolean fields a span of 1 and HTML or multi-line string fields a span of 4.
1777
+ # Give all other fields a span of 2.
1778
+ if field_def.data_field_type == FieldType.BOOLEAN:
1779
+ span = 1
1780
+ elif (isinstance(field_def, VeloxStringFieldDefinition)
1781
+ and (field_def.html_editor or field_def.num_lines > 1)):
1782
+ span = 4
1783
+ else:
1784
+ span = 2
1785
+ # Wrap the column position if necessary.
1786
+ if current_column + span > 4:
1787
+ current_column = 0
1788
+
1789
+ # Apply the field modifiers to each field in the form.
1790
+ modifier: FieldModifier = field_modifiers.get(field_name, default_modifier)
1791
+ builder.add_field(modifier.modify_field(field_def), current_column, span)
1792
+ current_column += span
1793
+
1794
+ return builder.get_temporary_data_type()
1795
+
1796
+ # CR-47309: Allow layouts to be provided in place of field names for record dialogs.
1797
+ def __temp_dt_from_layout(self, data_type: str, layout: DataTypeLayoutIdentifier,
1798
+ default_modifier: FieldModifier, field_modifiers: dict[str, FieldModifier]) \
1799
+ -> TemporaryDataType:
1800
+ """
1801
+ Construct a Temporary Data Type definition from a given data type name and layout identifier.
1802
+ Applies the provided field modifiers to the field definitions from the layout's temp data type to alter them
1803
+ from their system-set values
1804
+ """
1805
+ # Get the temp data type for the provided layout.
1806
+ temp_dt = self.dt_man.get_temporary_data_type(data_type, self.__to_layout_name(layout))
1807
+ # Apply the field modifiers to each field in the layout.
1808
+ for field_def in temp_dt.get_field_def_list():
1809
+ field_name: str = field_def.data_field_name
1810
+ modifier: FieldModifier = field_modifiers.get(field_name, default_modifier)
1811
+ temp_dt.set_field_definition(modifier.modify_field(field_def))
1812
+ return temp_dt
1813
+
1814
+ def __prompt_for_count(self, count: tuple[int, int] | int, wrapper_type: type[WrappedType] | str,
1815
+ require_input: bool, repeat_message: str) -> int:
1816
+ """
1817
+ Given a count value, if it is a tuple representing an allowable range of values for a number of records to
1818
+ create, prompt the user to input the exact count to use. If the count is already a single integer, simply
1819
+ return that.
1820
+ """
1821
+ if isinstance(count, tuple):
1822
+ if hasattr(wrapper_type, "PLURAL_DISPLAY_NAME"):
1823
+ plural: str = wrapper_type.PLURAL_DISPLAY_NAME
1824
+ else:
1825
+ plural: str = self.dt_cache.get_plural_display_name(AliasUtil.to_data_type_name(wrapper_type))
1826
+ min_val, max_val = count
1827
+ msg: str = f"How many {plural} should be created? ({min_val} to {max_val})"
1828
+ count: int = self.integer_input_dialog(f"Create {plural}", msg, "Count", min_val, min_val, max_val,
1829
+ require_input=require_input, repeat_message=repeat_message)
1830
+ return count
1831
+
1832
+ def __to_layout(self, data_type: str, layout: DataTypeLayoutIdentifier) -> DataTypeLayout | None:
1833
+ """
1834
+ Convert a data type layout identifier to a data type layout.
1835
+ """
1836
+ if layout is None:
1837
+ return None
1838
+ if isinstance(layout, DataTypeLayout):
1839
+ return layout
1840
+ layout_name: str = layout
1841
+ layout: DataTypeLayout | None = self.__get_data_type_layout(data_type, layout_name)
1842
+ # If a name was provided then the caller expects that name to exist. Throw an exception if it doesn't.
1843
+ if not layout:
1844
+ raise SapioException(f"The data type \"{data_type}\" does not have a layout by the name "
1845
+ f"\"{layout_name}\" in the system.")
1846
+ return layout
1847
+
1848
+ @staticmethod
1849
+ def __to_layout_name(layout: DataTypeLayoutIdentifier) -> str | None:
1850
+ """
1851
+ Convert a data type layout identifier to a layout name.
1852
+ """
1853
+ if layout is None:
1854
+ return None
1855
+ if isinstance(layout, DataTypeLayout):
1856
+ return layout.layout_name
1857
+ return layout
1858
+
1859
+ def __get_data_type_layout(self, data_type: str, layout: str) -> DataTypeLayout:
1860
+ """
1861
+ Get a data type layout from the cache given its name.
1862
+ """
1863
+ if data_type in self.__layouts:
1864
+ return self.__layouts[data_type].get(layout)
1865
+ self.__layouts[data_type] = {x.layout_name: x for x in self.dt_man.get_data_type_layout_list(data_type)}
1866
+ return self.__layouts[data_type].get(layout)
1867
+
1868
+ def __get_field_def(self, data_type: str, field_name: str) -> AbstractVeloxFieldDefinition:
1869
+ """
1870
+ Given a data type name and a data field name, return the field definition for that field on that data type.
1871
+ If the field name is an extension field, properly gets the field definition from the extension data type instead
1872
+ of the given data type and updates the extension field def to have its data field name match the given field
1873
+ name.
1874
+ """
1875
+ # CR-47311: Support displaying extension fields with single-data-type record dialogs.
1876
+ if "." in field_name:
1877
+ # If there is a period in the given field name, then this is an extension field.
1878
+ ext_dt, ext_fld = field_name.split(".")
1879
+ # Locate the extension data type's field definitions.
1880
+ field_def = self.dt_cache.get_fields_for_type(ext_dt).get(ext_fld)
1881
+ if field_def is None:
1882
+ raise SapioException(f"No field of name \"{ext_fld}\" in field definitions of extension type \"{ext_dt}\"")
1883
+ # Copy the field definition and set its field name to match the extension field name so that the record
1884
+ # field maps properly map the field value to the field definition.
1885
+ field_def = copy(field_def)
1886
+ field_def._data_field_name = field_name
1887
+ else:
1888
+ # If there is no period in the given field name, then this is a field on the base data type.
1889
+ field_def = self.dt_cache.get_fields_for_type(data_type).get(field_name)
1890
+ if field_def is None:
1891
+ raise SapioException(f"No field of name \"{field_name}\" in field definitions of type \"{data_type}\"")
1892
+ return field_def
1893
+
1894
+ def __handle_dialog_request(self, request: Any, func: Callable, **kwargs) -> Any:
1895
+ """
1896
+ Send a client callback request to the user that creates a dialog.
1897
+
1898
+ This function handles updating the user object's request timeout to match the request timeout of this
1899
+ CallbackUtil for the duration of the dialog.
1900
+ If the dialog times out then a SapioDialogTimeoutException is thrown.
1901
+ If the user cancels the dialog then a SapioUserCancelledException is thrown.
1902
+
1903
+ :param request: The client callback request to send to the user.
1904
+ :param func: The ClientCallback function to call with the given request as input.
1905
+ :param kwargs: Additional keywords for the provided function call.
1906
+ :return: The response from the client callback, if one was received.
1907
+ """
1908
+ try:
1909
+ self.user.timeout_seconds = self.timeout_seconds
1910
+ response: Any | None = func(request, **kwargs)
1911
+ except ReadTimeout:
1912
+ raise SapioDialogTimeoutException()
1913
+ finally:
1914
+ self.user.timeout_seconds = self._original_timeout
1915
+ if response is None:
1916
+ raise SapioUserCancelledException()
1917
+ return response
1118
1918
 
1119
1919
 
1120
1920
  class FieldModifier:
@@ -1159,29 +1959,99 @@ class FieldModifier:
1159
1959
  Apply modifications to a given field.
1160
1960
 
1161
1961
  :param field: The field to modify.
1162
- :return: A copy of the input field with the modifications applied.
1962
+ :return: A copy of the input field with the modifications applied. The input field is unchanged.
1163
1963
  """
1164
- field = copy_field(field)
1964
+ ret_val: AbstractVeloxFieldDefinition = copy(field)
1165
1965
  if self.prepend_data_type is True:
1166
- field._data_field_name = field.data_type_name + "." + field.data_field_name
1966
+ ret_val._data_field_name = ret_val.data_type_name + "." + ret_val.data_field_name
1167
1967
  if self.display_name is not None:
1168
- field.display_name = self.display_name
1968
+ ret_val.display_name = self.display_name
1169
1969
  if self.required is not None:
1170
- field.required = self.required
1970
+ ret_val.required = self.required
1171
1971
  if self.editable is not None:
1172
- field.editable = self.editable
1972
+ ret_val.editable = self.editable
1173
1973
  if self.visible is not None:
1174
- field.visible = self.visible
1974
+ ret_val.visible = self.visible
1175
1975
  if self.key_field is not None:
1176
- field.key_field = self.key_field
1976
+ ret_val.key_field = self.key_field
1177
1977
  if self.column_width is not None:
1178
- field.default_table_column_width = self.column_width
1179
- return field
1978
+ ret_val.default_table_column_width = self.column_width
1979
+ return ret_val
1180
1980
 
1181
1981
 
1182
- def copy_field(field: AbstractVeloxFieldDefinition) -> AbstractVeloxFieldDefinition:
1982
+ # CR-46866: Create a class that can be used by record-backed dialogs to filter for the fields displayed in the dialog
1983
+ # based on the attributes of the field definitions of the data type instead of requiring that the caller know the
1984
+ # names of the fields to be displayed.
1985
+ class FieldFilterCriteria:
1183
1986
  """
1184
- Create a copy of a given field definition. This is used to modify field definitions from the server for existing
1185
- data types without also modifying the field definition in the cache.
1987
+ A FieldFilterCriteria can be used to filter the fields that are displayed in certain record-backed client callbacks.
1186
1988
  """
1187
- return FieldDefinitionParser.to_field_definition(field.to_json())
1989
+ required: bool | None
1990
+ editable: bool | None
1991
+ key_field: bool | None
1992
+ identifier: bool | None
1993
+ system_field: bool | None
1994
+ field_types: Container[FieldType] | None
1995
+ not_field_types: Container[FieldType] | None
1996
+ matches_tag: str | None
1997
+ contains_tag: str | None
1998
+ regex_tag: str | re.Pattern[str] | None
1999
+
2000
+ def __init__(self, *, required: bool | None = None, editable: bool | None = None, key_field: bool | None = None,
2001
+ identifier: bool | None = None, system_field: bool | None = None,
2002
+ field_types: Container[FieldType] | None = None, not_field_types: Container[FieldType] | None = None,
2003
+ matches_tag: str | None = None, contains_tag: str | None = None,
2004
+ regex_tag: str | re.Pattern[str] | None = None):
2005
+ """
2006
+ Values that are left as None have no effect on the filtering. A field must match all non-None values in order
2007
+ to count as matching this filter.
2008
+
2009
+ :param required: Whether the field is required.
2010
+ :param editable: Whether the field is editable.
2011
+ :param key_field: Whether the field is a key field.
2012
+ :param identifier: Whether the field is an identifier field.
2013
+ :param system_field: Whether the field is a system field.
2014
+ :param field_types: Include fields matching these types.
2015
+ :param not_field_types: Exclude fields matching these types.
2016
+ :param matches_tag: If provided, the field's tag must exactly match this value.
2017
+ :param contains_tag: If provided, the field's tag must contain this value.
2018
+ :param regex_tag: If provided, the field's tag must match this regex.
2019
+ """
2020
+ self.required = required
2021
+ self.editable = editable
2022
+ self.key_field = key_field
2023
+ self.identifier = identifier
2024
+ self.system_field = system_field
2025
+ self.field_types = field_types
2026
+ self.not_field_types = not_field_types
2027
+ self.matches_tag = matches_tag
2028
+ self.contains_tag = contains_tag
2029
+ self.regex_tag = regex_tag
2030
+
2031
+ def field_matches(self, field: AbstractVeloxFieldDefinition) -> bool:
2032
+ """
2033
+ :param field: A field definition from a data type.
2034
+ :return: Whether the field definition matches the filter criteria.
2035
+ """
2036
+ ret_val: bool = True
2037
+ if self.required is not None:
2038
+ ret_val = ret_val and self.required == field.required
2039
+ if self.editable is not None:
2040
+ ret_val = ret_val and self.editable == field.editable
2041
+ if self.key_field is not None:
2042
+ ret_val = ret_val and self.key_field == field.key_field
2043
+ if self.identifier is not None:
2044
+ ret_val = ret_val and self.identifier == field.identifier
2045
+ if self.system_field is not None:
2046
+ ret_val = ret_val and self.system_field == field.system_field
2047
+ if self.field_types is not None:
2048
+ ret_val = ret_val and field.data_field_type in self.field_types
2049
+ if self.not_field_types is not None:
2050
+ ret_val = ret_val and field.data_field_type not in self.not_field_types
2051
+ if self.matches_tag is not None:
2052
+ ret_val = ret_val and field.tag is not None and self.matches_tag == field.tag
2053
+ if self.contains_tag is not None:
2054
+ ret_val = ret_val and field.tag is not None and self.contains_tag in field.tag
2055
+ if self.regex_tag is not None:
2056
+ ret_val = ret_val and field.tag is not None and bool(re.match(self.regex_tag, field.tag))
2057
+ return ret_val