mcp-eregistrations-bpa 0.8.5__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 mcp-eregistrations-bpa might be problematic. Click here for more details.

Files changed (66) hide show
  1. mcp_eregistrations_bpa/__init__.py +121 -0
  2. mcp_eregistrations_bpa/__main__.py +6 -0
  3. mcp_eregistrations_bpa/arazzo/__init__.py +21 -0
  4. mcp_eregistrations_bpa/arazzo/expression.py +379 -0
  5. mcp_eregistrations_bpa/audit/__init__.py +56 -0
  6. mcp_eregistrations_bpa/audit/context.py +66 -0
  7. mcp_eregistrations_bpa/audit/logger.py +236 -0
  8. mcp_eregistrations_bpa/audit/models.py +131 -0
  9. mcp_eregistrations_bpa/auth/__init__.py +64 -0
  10. mcp_eregistrations_bpa/auth/callback.py +391 -0
  11. mcp_eregistrations_bpa/auth/cas.py +409 -0
  12. mcp_eregistrations_bpa/auth/oidc.py +252 -0
  13. mcp_eregistrations_bpa/auth/permissions.py +162 -0
  14. mcp_eregistrations_bpa/auth/token_manager.py +348 -0
  15. mcp_eregistrations_bpa/bpa_client/__init__.py +84 -0
  16. mcp_eregistrations_bpa/bpa_client/client.py +740 -0
  17. mcp_eregistrations_bpa/bpa_client/endpoints.py +193 -0
  18. mcp_eregistrations_bpa/bpa_client/errors.py +276 -0
  19. mcp_eregistrations_bpa/bpa_client/models.py +203 -0
  20. mcp_eregistrations_bpa/config.py +349 -0
  21. mcp_eregistrations_bpa/db/__init__.py +21 -0
  22. mcp_eregistrations_bpa/db/connection.py +64 -0
  23. mcp_eregistrations_bpa/db/migrations.py +168 -0
  24. mcp_eregistrations_bpa/exceptions.py +39 -0
  25. mcp_eregistrations_bpa/py.typed +0 -0
  26. mcp_eregistrations_bpa/rollback/__init__.py +19 -0
  27. mcp_eregistrations_bpa/rollback/manager.py +616 -0
  28. mcp_eregistrations_bpa/server.py +152 -0
  29. mcp_eregistrations_bpa/tools/__init__.py +372 -0
  30. mcp_eregistrations_bpa/tools/actions.py +155 -0
  31. mcp_eregistrations_bpa/tools/analysis.py +352 -0
  32. mcp_eregistrations_bpa/tools/audit.py +399 -0
  33. mcp_eregistrations_bpa/tools/behaviours.py +1042 -0
  34. mcp_eregistrations_bpa/tools/bots.py +627 -0
  35. mcp_eregistrations_bpa/tools/classifications.py +575 -0
  36. mcp_eregistrations_bpa/tools/costs.py +765 -0
  37. mcp_eregistrations_bpa/tools/debug_strategies.py +351 -0
  38. mcp_eregistrations_bpa/tools/debugger.py +1230 -0
  39. mcp_eregistrations_bpa/tools/determinants.py +2235 -0
  40. mcp_eregistrations_bpa/tools/document_requirements.py +670 -0
  41. mcp_eregistrations_bpa/tools/export.py +899 -0
  42. mcp_eregistrations_bpa/tools/fields.py +162 -0
  43. mcp_eregistrations_bpa/tools/form_errors.py +36 -0
  44. mcp_eregistrations_bpa/tools/formio_helpers.py +971 -0
  45. mcp_eregistrations_bpa/tools/forms.py +1269 -0
  46. mcp_eregistrations_bpa/tools/jsonlogic_builder.py +466 -0
  47. mcp_eregistrations_bpa/tools/large_response.py +163 -0
  48. mcp_eregistrations_bpa/tools/messages.py +523 -0
  49. mcp_eregistrations_bpa/tools/notifications.py +241 -0
  50. mcp_eregistrations_bpa/tools/registration_institutions.py +680 -0
  51. mcp_eregistrations_bpa/tools/registrations.py +897 -0
  52. mcp_eregistrations_bpa/tools/role_status.py +447 -0
  53. mcp_eregistrations_bpa/tools/role_units.py +400 -0
  54. mcp_eregistrations_bpa/tools/roles.py +1236 -0
  55. mcp_eregistrations_bpa/tools/rollback.py +335 -0
  56. mcp_eregistrations_bpa/tools/services.py +674 -0
  57. mcp_eregistrations_bpa/tools/workflows.py +2487 -0
  58. mcp_eregistrations_bpa/tools/yaml_transformer.py +991 -0
  59. mcp_eregistrations_bpa/workflows/__init__.py +28 -0
  60. mcp_eregistrations_bpa/workflows/loader.py +440 -0
  61. mcp_eregistrations_bpa/workflows/models.py +336 -0
  62. mcp_eregistrations_bpa-0.8.5.dist-info/METADATA +965 -0
  63. mcp_eregistrations_bpa-0.8.5.dist-info/RECORD +66 -0
  64. mcp_eregistrations_bpa-0.8.5.dist-info/WHEEL +4 -0
  65. mcp_eregistrations_bpa-0.8.5.dist-info/entry_points.txt +2 -0
  66. mcp_eregistrations_bpa-0.8.5.dist-info/licenses/LICENSE +86 -0
@@ -0,0 +1,971 @@
1
+ """Form.io schema manipulation helpers.
2
+
3
+ This module provides utility functions for manipulating Form.io component trees.
4
+ It supports finding, inserting, removing, and updating components within the
5
+ nested structure (panels, columns, tabs, etc.).
6
+
7
+ All functions operate on copies to avoid mutating original data.
8
+
9
+ Container Type Hierarchy
10
+ ------------------------
11
+ Form.io uses container components to organize form layouts. Each container type
12
+ stores its children in a specific property:
13
+
14
+ +-------------+---------------------------+----------------------------------------+
15
+ | Type | Children Accessor | Notes |
16
+ +-------------+---------------------------+----------------------------------------+
17
+ | tabs | components[] | Tab panes have null type; each pane |
18
+ | | | contains its own components[] |
19
+ +-------------+---------------------------+----------------------------------------+
20
+ | panel | components[] | Standard collapsible container |
21
+ +-------------+---------------------------+----------------------------------------+
22
+ | columns | columns[].components[] | 2-level nesting: columns array with |
23
+ | | | each column having a components array |
24
+ +-------------+---------------------------+----------------------------------------+
25
+ | fieldset | components[] | HTML fieldset grouping |
26
+ +-------------+---------------------------+----------------------------------------+
27
+ | editgrid | components[] | Repeatable rows with inline editing |
28
+ +-------------+---------------------------+----------------------------------------+
29
+ | datagrid | components[] | Repeatable rows in table format |
30
+ +-------------+---------------------------+----------------------------------------+
31
+ | table | rows[][] | HTML table structure: rows array |
32
+ | | | containing arrays of cells |
33
+ +-------------+---------------------------+----------------------------------------+
34
+ | well | components[] | Visual grouping container |
35
+ +-------------+---------------------------+----------------------------------------+
36
+ | container | components[] | Data grouping without visual styling |
37
+ +-------------+---------------------------+----------------------------------------+
38
+
39
+ Path Examples
40
+ -------------
41
+ When traversing nested components, paths represent the hierarchy from root to
42
+ the target component:
43
+
44
+ # Example: component inside tabs > tab_pane > panel
45
+ path = ["applicantWelcome", "applicantWelcomepersonalInformation", "applicantBlock"]
46
+ # Meaning: tabs("applicantWelcome") > panel("applicantWelcomepersonalInformation")
47
+ # > panel("applicantBlock") > [target component]
48
+
49
+ # Example: component inside columns
50
+ path = ["myPanel", "twoColumnLayout"]
51
+ # Meaning: panel("myPanel") > columns("twoColumnLayout") > [target component]
52
+
53
+ The path array is returned by find_component() and used in API responses
54
+ to help identify the nesting structure of each component.
55
+ """
56
+
57
+ from __future__ import annotations
58
+
59
+ import copy
60
+ import re
61
+ from typing import Any
62
+
63
+ # Container component types that can hold nested components
64
+ CONTAINER_TYPES = {
65
+ "panel",
66
+ "columns",
67
+ "tabs",
68
+ "well",
69
+ "fieldset",
70
+ "container",
71
+ "editgrid",
72
+ "datagrid",
73
+ "table",
74
+ }
75
+
76
+ # Maximum recursion depth for tree traversal (prevent stack overflow)
77
+ MAX_RECURSION_DEPTH = 100
78
+
79
+ # BPA-specific default properties for new components
80
+ BPA_COMPONENT_DEFAULTS = {
81
+ "registrations": {},
82
+ "determinantIds": [],
83
+ "componentActionId": "",
84
+ "componentFormulaId": "",
85
+ "behaviourId": "",
86
+ "componentValidationId": "",
87
+ "version": "201905",
88
+ "input": True,
89
+ "tableView": True,
90
+ }
91
+
92
+
93
+ def find_component(
94
+ components: list[dict[str, Any]],
95
+ key: str,
96
+ path: list[str] | None = None,
97
+ _depth: int = 0,
98
+ ) -> tuple[dict[str, Any], list[str]] | None:
99
+ """Find a component by key in the component tree.
100
+
101
+ Searches recursively through nested components (panels, columns, tabs).
102
+
103
+ Args:
104
+ components: List of Form.io components to search.
105
+ key: The component key to find.
106
+ path: Current path (used internally for recursion).
107
+ _depth: Current recursion depth (internal use).
108
+
109
+ Returns:
110
+ Tuple of (component, path) if found, None otherwise.
111
+ The path is a list of parent keys leading to the component.
112
+
113
+ Raises:
114
+ RecursionError: If nesting exceeds MAX_RECURSION_DEPTH.
115
+
116
+ Example:
117
+ >>> components = [{"key": "panel1", "type": "panel", "components": [
118
+ ... {"key": "name", "type": "textfield"}
119
+ ... ]}]
120
+ >>> result = find_component(components, "name")
121
+ >>> result[0]["key"]
122
+ 'name'
123
+ >>> result[1]
124
+ ['panel1']
125
+ """
126
+ if _depth > MAX_RECURSION_DEPTH:
127
+ raise RecursionError(
128
+ f"Component tree exceeds maximum depth of {MAX_RECURSION_DEPTH}"
129
+ )
130
+
131
+ if path is None:
132
+ path = []
133
+
134
+ for comp in components:
135
+ if not isinstance(comp, dict):
136
+ continue
137
+
138
+ comp_key = comp.get("key")
139
+ if comp_key == key:
140
+ return (comp, path)
141
+
142
+ new_path = path + [comp_key] if comp_key else path
143
+
144
+ # Search in nested components
145
+ nested = comp.get("components", [])
146
+ if nested:
147
+ result = find_component(nested, key, new_path, _depth + 1)
148
+ if result:
149
+ return result
150
+
151
+ # Search in columns
152
+ columns = comp.get("columns", [])
153
+ for col in columns:
154
+ if isinstance(col, dict):
155
+ col_components = col.get("components", [])
156
+ if col_components:
157
+ result = find_component(col_components, key, new_path, _depth + 1)
158
+ if result:
159
+ return result
160
+
161
+ # Search in tabs
162
+ tabs = comp.get("tabs", [])
163
+ for tab in tabs:
164
+ if isinstance(tab, dict):
165
+ tab_components = tab.get("components", [])
166
+ if tab_components:
167
+ result = find_component(tab_components, key, new_path, _depth + 1)
168
+ if result:
169
+ return result
170
+
171
+ # Search in table rows (rows[][] structure)
172
+ rows = comp.get("rows", [])
173
+ for row in rows:
174
+ if isinstance(row, list):
175
+ for cell in row:
176
+ if isinstance(cell, dict):
177
+ cell_components = cell.get("components", [])
178
+ if cell_components:
179
+ result = find_component(
180
+ cell_components, key, new_path, _depth + 1
181
+ )
182
+ if result:
183
+ return result
184
+
185
+ return None
186
+
187
+
188
+ def find_component_parent(
189
+ components: list[dict[str, Any]],
190
+ key: str,
191
+ ) -> tuple[dict[str, Any] | None, list[dict[str, Any]], int]:
192
+ """Find the parent container and index of a component.
193
+
194
+ Args:
195
+ components: List of Form.io components.
196
+ key: The component key to find.
197
+
198
+ Returns:
199
+ Tuple of (parent_component, siblings_list, index).
200
+ parent_component is None if the component is at root level.
201
+ siblings_list is the list containing the component.
202
+ index is the position of the component in siblings_list.
203
+
204
+ Raises:
205
+ ValueError: If component is not found.
206
+ RecursionError: If nesting exceeds MAX_RECURSION_DEPTH.
207
+ """
208
+
209
+ def _search(
210
+ comps: list[dict[str, Any]], parent: dict[str, Any] | None, depth: int = 0
211
+ ) -> tuple[dict[str, Any] | None, list[dict[str, Any]], int] | None:
212
+ if depth > MAX_RECURSION_DEPTH:
213
+ raise RecursionError(
214
+ f"Component tree exceeds maximum depth of {MAX_RECURSION_DEPTH}"
215
+ )
216
+
217
+ for i, comp in enumerate(comps):
218
+ if not isinstance(comp, dict):
219
+ continue
220
+
221
+ if comp.get("key") == key:
222
+ return (parent, comps, i)
223
+
224
+ # Search in nested components
225
+ nested = comp.get("components", [])
226
+ if nested:
227
+ result = _search(nested, comp, depth + 1)
228
+ if result:
229
+ return result
230
+
231
+ # Search in columns
232
+ columns = comp.get("columns", [])
233
+ for col in columns:
234
+ if isinstance(col, dict):
235
+ col_components = col.get("components", [])
236
+ if col_components:
237
+ result = _search(col_components, comp, depth + 1)
238
+ if result:
239
+ return result
240
+
241
+ # Search in tabs
242
+ tabs = comp.get("tabs", [])
243
+ for tab in tabs:
244
+ if isinstance(tab, dict):
245
+ tab_components = tab.get("components", [])
246
+ if tab_components:
247
+ result = _search(tab_components, comp, depth + 1)
248
+ if result:
249
+ return result
250
+
251
+ # Search in table rows (rows[][] structure)
252
+ rows = comp.get("rows", [])
253
+ for row in rows:
254
+ if isinstance(row, list):
255
+ for cell in row:
256
+ if isinstance(cell, dict):
257
+ cell_components = cell.get("components", [])
258
+ if cell_components:
259
+ result = _search(cell_components, comp, depth + 1)
260
+ if result:
261
+ return result
262
+
263
+ return None
264
+
265
+ result = _search(components, None)
266
+ if result is None:
267
+ raise ValueError(f"Component with key '{key}' not found")
268
+ return result
269
+
270
+
271
+ def get_all_component_keys(
272
+ components: list[dict[str, Any]],
273
+ include_duplicates: bool = False,
274
+ ) -> set[str] | list[str]:
275
+ """Get all component keys in the tree.
276
+
277
+ Args:
278
+ components: List of Form.io components.
279
+ include_duplicates: If True, returns list with duplicates preserved.
280
+ If False (default), returns set of unique keys.
281
+
282
+ Returns:
283
+ Set of unique keys, or list with duplicates if include_duplicates=True.
284
+
285
+ Raises:
286
+ RecursionError: If nesting exceeds MAX_RECURSION_DEPTH.
287
+ """
288
+ # Handle non-list components (BPA API may return int or other types)
289
+ if not isinstance(components, list):
290
+ return [] if include_duplicates else set()
291
+
292
+ keys: list[str] = []
293
+
294
+ def _collect(comps: list[dict[str, Any]], depth: int = 0) -> None:
295
+ if depth > MAX_RECURSION_DEPTH:
296
+ raise RecursionError(
297
+ f"Component tree exceeds maximum depth of {MAX_RECURSION_DEPTH}"
298
+ )
299
+
300
+ # Skip if comps is not a list (defensive for recursive calls)
301
+ if not isinstance(comps, list):
302
+ return
303
+
304
+ for comp in comps:
305
+ if not isinstance(comp, dict):
306
+ continue
307
+
308
+ key = comp.get("key")
309
+ if key:
310
+ keys.append(key)
311
+
312
+ # Collect from nested components
313
+ nested = comp.get("components", [])
314
+ if nested and isinstance(nested, list):
315
+ _collect(nested, depth + 1)
316
+
317
+ # Collect from columns
318
+ columns = comp.get("columns", [])
319
+ if isinstance(columns, list):
320
+ for col in columns:
321
+ if isinstance(col, dict):
322
+ col_components = col.get("components", [])
323
+ if col_components and isinstance(col_components, list):
324
+ _collect(col_components, depth + 1)
325
+
326
+ # Collect from tabs
327
+ tabs = comp.get("tabs", [])
328
+ if isinstance(tabs, list):
329
+ for tab in tabs:
330
+ if isinstance(tab, dict):
331
+ tab_components = tab.get("components", [])
332
+ if tab_components and isinstance(tab_components, list):
333
+ _collect(tab_components, depth + 1)
334
+
335
+ _collect(components)
336
+
337
+ if include_duplicates:
338
+ return keys
339
+ return set(keys)
340
+
341
+
342
+ def validate_component_key_unique(
343
+ components: list[dict[str, Any]],
344
+ key: str,
345
+ ) -> bool:
346
+ """Check if a key is unique within the component tree.
347
+
348
+ Args:
349
+ components: Existing Form.io components.
350
+ key: Key to check for uniqueness.
351
+
352
+ Returns:
353
+ True if key is unique, False if it already exists.
354
+ """
355
+ existing = get_all_component_keys(components)
356
+ return key not in existing
357
+
358
+
359
+ def insert_component(
360
+ components: list[dict[str, Any]],
361
+ component: dict[str, Any],
362
+ parent_key: str | None = None,
363
+ position: int | None = None,
364
+ ) -> list[dict[str, Any]]:
365
+ """Insert a component into the tree.
366
+
367
+ Creates a deep copy of the components and inserts the new component.
368
+ Does not mutate the original list.
369
+
370
+ Args:
371
+ components: Original Form.io components list.
372
+ component: Component to insert.
373
+ parent_key: Key of parent container to insert into.
374
+ If None, inserts at root level.
375
+ position: Position to insert at (0-indexed).
376
+ If None, appends to end.
377
+
378
+ Returns:
379
+ New components list with the component inserted.
380
+
381
+ Raises:
382
+ ValueError: If parent_key is specified but not found,
383
+ or if parent is not a container type.
384
+ """
385
+ # NOTE: Deep copy ensures immutability - input components are never mutated.
386
+ # For large forms (500+ components), this may impact performance (~50-100ms).
387
+ # If performance becomes critical, consider structural sharing optimization.
388
+ result = copy.deepcopy(components)
389
+ new_comp = copy.deepcopy(component)
390
+
391
+ # Add BPA defaults if not present
392
+ for key, value in BPA_COMPONENT_DEFAULTS.items():
393
+ if key not in new_comp:
394
+ new_comp[key] = copy.deepcopy(value)
395
+
396
+ if parent_key is None:
397
+ # Insert at root level
398
+ if position is None:
399
+ result.append(new_comp)
400
+ else:
401
+ result.insert(position, new_comp)
402
+ else:
403
+ # Find parent and insert
404
+ parent_result = find_component(result, parent_key)
405
+ if parent_result is None:
406
+ raise ValueError(f"Parent component '{parent_key}' not found")
407
+
408
+ parent_comp = parent_result[0]
409
+ parent_type = parent_comp.get("type", "")
410
+
411
+ if parent_type not in CONTAINER_TYPES:
412
+ raise ValueError(
413
+ f"Component '{parent_key}' (type: {parent_type}) is not a container. "
414
+ f"Valid container types: {', '.join(sorted(CONTAINER_TYPES))}"
415
+ )
416
+
417
+ # Ensure components list exists
418
+ if "components" not in parent_comp:
419
+ parent_comp["components"] = []
420
+
421
+ if position is None:
422
+ parent_comp["components"].append(new_comp)
423
+ else:
424
+ parent_comp["components"].insert(position, new_comp)
425
+
426
+ return result
427
+
428
+
429
+ def remove_component(
430
+ components: list[dict[str, Any]],
431
+ key: str,
432
+ ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
433
+ """Remove a component from the tree.
434
+
435
+ Creates a deep copy and removes the component.
436
+
437
+ Args:
438
+ components: Original Form.io components list.
439
+ key: Key of component to remove.
440
+
441
+ Returns:
442
+ Tuple of (new_components, removed_component).
443
+
444
+ Raises:
445
+ ValueError: If component is not found.
446
+ """
447
+ # Deep copy ensures immutability (see insert_component for performance notes)
448
+ result = copy.deepcopy(components)
449
+
450
+ # Find and remove
451
+ parent, siblings, index = find_component_parent(result, key)
452
+ removed = siblings.pop(index)
453
+
454
+ return (result, removed)
455
+
456
+
457
+ def update_component(
458
+ components: list[dict[str, Any]],
459
+ key: str,
460
+ updates: dict[str, Any],
461
+ ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
462
+ """Update properties of a component.
463
+
464
+ Merges updates into the existing component. Deep merges nested dicts.
465
+
466
+ Args:
467
+ components: Original Form.io components list.
468
+ key: Key of component to update.
469
+ updates: Properties to update/add.
470
+
471
+ Returns:
472
+ Tuple of (new_components, previous_state).
473
+ previous_state contains the component's state before update.
474
+
475
+ Raises:
476
+ ValueError: If component is not found.
477
+ """
478
+ # Deep copy ensures immutability (see insert_component for performance notes)
479
+ result = copy.deepcopy(components)
480
+
481
+ found = find_component(result, key)
482
+ if found is None:
483
+ raise ValueError(f"Component with key '{key}' not found")
484
+
485
+ comp = found[0]
486
+ previous_state = copy.deepcopy(comp)
487
+
488
+ # Deep merge updates
489
+ _deep_merge(comp, updates)
490
+
491
+ return (result, previous_state)
492
+
493
+
494
+ def move_component(
495
+ components: list[dict[str, Any]],
496
+ key: str,
497
+ new_parent_key: str | None = None,
498
+ new_position: int | None = None,
499
+ ) -> tuple[list[dict[str, Any]], dict[str, str | int | None]]:
500
+ """Move a component to a new location in the tree.
501
+
502
+ Args:
503
+ components: Original Form.io components list.
504
+ key: Key of component to move.
505
+ new_parent_key: New parent container key, or None for root.
506
+ new_position: Position in new parent, or None for end.
507
+
508
+ Returns:
509
+ Tuple of (new_components, move_info).
510
+ move_info contains old_parent, old_position, new_parent, new_position.
511
+
512
+ Raises:
513
+ ValueError: If component or new parent not found.
514
+ """
515
+ # First remove the component
516
+ result, removed = remove_component(components, key)
517
+
518
+ # Track old position
519
+ old_parent, _, old_index = find_component_parent(components, key)
520
+ old_parent_key = old_parent.get("key") if old_parent else None
521
+
522
+ # Insert at new location
523
+ result = insert_component(result, removed, new_parent_key, new_position)
524
+
525
+ # Determine actual new position
526
+ actual_position: int | None = None
527
+ if new_parent_key:
528
+ new_parent = find_component(result, new_parent_key)
529
+ if new_parent:
530
+ new_siblings = new_parent[0].get("components", [])
531
+ actual_position = (
532
+ new_position if new_position is not None else len(new_siblings) - 1
533
+ )
534
+ else:
535
+ actual_position = new_position
536
+ else:
537
+ actual_position = new_position if new_position is not None else len(result) - 1
538
+
539
+ move_info = {
540
+ "old_parent": old_parent_key,
541
+ "old_position": old_index,
542
+ "new_parent": new_parent_key,
543
+ "new_position": actual_position,
544
+ }
545
+
546
+ return (result, move_info)
547
+
548
+
549
+ def _deep_merge(target: dict[str, Any], source: dict[str, Any]) -> None:
550
+ """Deep merge source dict into target dict (mutates target).
551
+
552
+ Args:
553
+ target: Dict to merge into.
554
+ source: Dict to merge from.
555
+ """
556
+ for key, value in source.items():
557
+ if key in target and isinstance(target[key], dict) and isinstance(value, dict):
558
+ _deep_merge(target[key], value)
559
+ else:
560
+ target[key] = copy.deepcopy(value)
561
+
562
+
563
+ # =============================================================================
564
+ # Component Builders - Convenience functions for creating common components
565
+ # =============================================================================
566
+
567
+
568
+ def build_textfield(
569
+ key: str,
570
+ label: str,
571
+ *,
572
+ required: bool = False,
573
+ placeholder: str = "",
574
+ tooltip: str = "",
575
+ description: str = "",
576
+ size: str = "md",
577
+ hidden: bool = False,
578
+ disabled: bool = False,
579
+ default_value: str = "",
580
+ ) -> dict[str, Any]:
581
+ """Build a textfield component with BPA-specific properties.
582
+
583
+ Args:
584
+ key: Unique component key (e.g., "applicantFullName").
585
+ label: Display label.
586
+ required: Whether field is required.
587
+ placeholder: Placeholder text.
588
+ tooltip: Tooltip text.
589
+ description: Field description.
590
+ size: Field size ("sm", "md", "lg").
591
+ hidden: Whether field is hidden.
592
+ disabled: Whether field is disabled.
593
+ default_value: Default value.
594
+
595
+ Returns:
596
+ Complete textfield component definition.
597
+ """
598
+ component: dict[str, Any] = {
599
+ "type": "textfield",
600
+ "key": key,
601
+ "label": label,
602
+ "size": size,
603
+ "validate": {"required": required},
604
+ "input": True,
605
+ "tableView": True,
606
+ **BPA_COMPONENT_DEFAULTS,
607
+ }
608
+
609
+ if placeholder:
610
+ component["placeholder"] = placeholder
611
+ if tooltip:
612
+ component["tooltip"] = tooltip
613
+ if description:
614
+ component["description"] = description
615
+ if hidden:
616
+ component["hidden"] = True
617
+ if disabled:
618
+ component["disabled"] = True
619
+ if default_value:
620
+ component["defaultValue"] = default_value
621
+
622
+ return component
623
+
624
+
625
+ def build_number(
626
+ key: str,
627
+ label: str,
628
+ *,
629
+ required: bool = False,
630
+ min_value: float | None = None,
631
+ max_value: float | None = None,
632
+ decimal_limit: int | None = None,
633
+ default_value: float | None = None,
634
+ ) -> dict[str, Any]:
635
+ """Build a number component.
636
+
637
+ Args:
638
+ key: Unique component key.
639
+ label: Display label.
640
+ required: Whether field is required.
641
+ min_value: Minimum allowed value.
642
+ max_value: Maximum allowed value.
643
+ decimal_limit: Maximum decimal places.
644
+ default_value: Default value.
645
+
646
+ Returns:
647
+ Complete number component definition.
648
+ """
649
+ component: dict[str, Any] = {
650
+ "type": "number",
651
+ "key": key,
652
+ "label": label,
653
+ "validate": {"required": required},
654
+ "input": True,
655
+ "tableView": True,
656
+ **BPA_COMPONENT_DEFAULTS,
657
+ }
658
+
659
+ if min_value is not None:
660
+ component["validate"]["min"] = min_value
661
+ if max_value is not None:
662
+ component["validate"]["max"] = max_value
663
+ if decimal_limit is not None:
664
+ component["decimalLimit"] = decimal_limit
665
+ if default_value is not None:
666
+ component["defaultValue"] = default_value
667
+
668
+ return component
669
+
670
+
671
+ def build_select(
672
+ key: str,
673
+ label: str,
674
+ *,
675
+ required: bool = False,
676
+ data_source: str = "values",
677
+ values: list[dict[str, str]] | None = None,
678
+ catalog: str | None = None,
679
+ multiple: bool = False,
680
+ placeholder: str = "",
681
+ ) -> dict[str, Any]:
682
+ """Build a select/dropdown component.
683
+
684
+ Args:
685
+ key: Unique component key.
686
+ label: Display label.
687
+ required: Whether field is required.
688
+ data_source: Data source type ("values", "Catalogue", "url").
689
+ values: List of {"label": ..., "value": ...} options.
690
+ catalog: Catalog name (when data_source is "Catalogue").
691
+ multiple: Allow multiple selections.
692
+ placeholder: Placeholder text.
693
+
694
+ Returns:
695
+ Complete select component definition.
696
+ """
697
+ component: dict[str, Any] = {
698
+ "type": "select",
699
+ "key": key,
700
+ "label": label,
701
+ "validate": {"required": required},
702
+ "input": True,
703
+ "tableView": True,
704
+ "data": {"dataSrc": data_source},
705
+ **BPA_COMPONENT_DEFAULTS,
706
+ }
707
+
708
+ if data_source == "values" and values:
709
+ component["data"]["values"] = values
710
+ elif data_source == "Catalogue" and catalog:
711
+ component["data"]["catalog"] = catalog
712
+ component["data"]["systemSource"] = "BPA"
713
+
714
+ if multiple:
715
+ component["multiple"] = True
716
+ if placeholder:
717
+ component["placeholder"] = placeholder
718
+
719
+ return component
720
+
721
+
722
+ def build_checkbox(
723
+ key: str,
724
+ label: str,
725
+ *,
726
+ required: bool = False,
727
+ default_value: bool = False,
728
+ ) -> dict[str, Any]:
729
+ """Build a checkbox component.
730
+
731
+ Args:
732
+ key: Unique component key.
733
+ label: Display label.
734
+ required: Whether field is required.
735
+ default_value: Default checked state.
736
+
737
+ Returns:
738
+ Complete checkbox component definition.
739
+ """
740
+ return {
741
+ "type": "checkbox",
742
+ "key": key,
743
+ "label": label,
744
+ "validate": {"required": required},
745
+ "defaultValue": default_value,
746
+ "input": True,
747
+ "tableView": True,
748
+ **BPA_COMPONENT_DEFAULTS,
749
+ }
750
+
751
+
752
+ def build_textarea(
753
+ key: str,
754
+ label: str,
755
+ *,
756
+ required: bool = False,
757
+ rows: int = 3,
758
+ placeholder: str = "",
759
+ ) -> dict[str, Any]:
760
+ """Build a textarea component.
761
+
762
+ Args:
763
+ key: Unique component key.
764
+ label: Display label.
765
+ required: Whether field is required.
766
+ rows: Number of visible rows.
767
+ placeholder: Placeholder text.
768
+
769
+ Returns:
770
+ Complete textarea component definition.
771
+ """
772
+ component: dict[str, Any] = {
773
+ "type": "textarea",
774
+ "key": key,
775
+ "label": label,
776
+ "rows": rows,
777
+ "validate": {"required": required},
778
+ "input": True,
779
+ "tableView": True,
780
+ **BPA_COMPONENT_DEFAULTS,
781
+ }
782
+
783
+ if placeholder:
784
+ component["placeholder"] = placeholder
785
+
786
+ return component
787
+
788
+
789
+ def build_panel(
790
+ key: str,
791
+ title: str,
792
+ *,
793
+ components: list[dict[str, Any]] | None = None,
794
+ collapsible: bool = False,
795
+ collapsed: bool = False,
796
+ ) -> dict[str, Any]:
797
+ """Build a panel container component.
798
+
799
+ Args:
800
+ key: Unique component key.
801
+ title: Panel title.
802
+ components: Nested components.
803
+ collapsible: Whether panel can be collapsed.
804
+ collapsed: Initial collapsed state.
805
+
806
+ Returns:
807
+ Complete panel component definition.
808
+ """
809
+ panel: dict[str, Any] = {
810
+ "type": "panel",
811
+ "key": key,
812
+ "title": title,
813
+ "components": components or [],
814
+ "input": False,
815
+ "tableView": False,
816
+ **BPA_COMPONENT_DEFAULTS,
817
+ }
818
+
819
+ if collapsible:
820
+ panel["collapsible"] = True
821
+ panel["collapsed"] = collapsed
822
+
823
+ return panel
824
+
825
+
826
+ def build_columns(
827
+ key: str,
828
+ *,
829
+ column_count: int = 2,
830
+ components_per_column: list[list[dict[str, Any]]] | None = None,
831
+ ) -> dict[str, Any]:
832
+ """Build a columns layout component.
833
+
834
+ Args:
835
+ key: Unique component key.
836
+ column_count: Number of columns (default 2).
837
+ components_per_column: List of component lists for each column.
838
+
839
+ Returns:
840
+ Complete columns component definition.
841
+ """
842
+ columns = []
843
+ width = 12 // column_count # Bootstrap grid (12 columns)
844
+
845
+ for i in range(column_count):
846
+ col_components = (
847
+ components_per_column[i]
848
+ if components_per_column and i < len(components_per_column)
849
+ else []
850
+ )
851
+ columns.append(
852
+ {
853
+ "components": col_components,
854
+ "width": width,
855
+ "offset": 0,
856
+ "push": 0,
857
+ "pull": 0,
858
+ "size": "md",
859
+ }
860
+ )
861
+
862
+ return {
863
+ "type": "columns",
864
+ "key": key,
865
+ "columns": columns,
866
+ "input": False,
867
+ "tableView": False,
868
+ **BPA_COMPONENT_DEFAULTS,
869
+ }
870
+
871
+
872
+ # =============================================================================
873
+ # Validation Functions
874
+ # =============================================================================
875
+
876
+
877
+ def validate_component(component: dict[str, Any]) -> list[str]:
878
+ """Validate a component structure.
879
+
880
+ Args:
881
+ component: Component to validate.
882
+
883
+ Returns:
884
+ List of validation error messages (empty if valid).
885
+ """
886
+ errors: list[str] = []
887
+
888
+ # Required fields
889
+ if not component.get("key"):
890
+ errors.append("Component must have a 'key' property")
891
+ elif not _is_valid_key(component["key"]):
892
+ errors.append(
893
+ f"Invalid key format '{component['key']}'. "
894
+ "Keys must be alphanumeric with underscores, starting with a letter."
895
+ )
896
+
897
+ if not component.get("type"):
898
+ errors.append("Component must have a 'type' property")
899
+
900
+ # Type-specific validation
901
+ comp_type = component.get("type", "")
902
+
903
+ if comp_type in ("textfield", "textarea", "number", "select", "checkbox"):
904
+ if not component.get("label"):
905
+ errors.append(
906
+ f"Component type '{comp_type}' should have a 'label' property"
907
+ )
908
+
909
+ if comp_type == "select":
910
+ data = component.get("data", {})
911
+ data_src = data.get("dataSrc", "values")
912
+ if data_src == "values" and not data.get("values"):
913
+ # Values source but no values - just a warning
914
+ pass
915
+ elif data_src == "Catalogue" and not data.get("catalog"):
916
+ errors.append(
917
+ "Select with Catalogue source must have 'data.catalog' property"
918
+ )
919
+
920
+ return errors
921
+
922
+
923
+ def _is_valid_key(key: str) -> bool:
924
+ """Check if a key follows valid naming conventions.
925
+
926
+ Args:
927
+ key: Component key to validate.
928
+
929
+ Returns:
930
+ True if key is valid.
931
+ """
932
+ # Must start with letter, contain only alphanumeric and underscores
933
+ return bool(re.match(r"^[a-zA-Z][a-zA-Z0-9_]*$", key))
934
+
935
+
936
+ def validate_form_schema(schema: dict[str, Any]) -> list[str]:
937
+ """Validate a complete form schema.
938
+
939
+ Args:
940
+ schema: Form schema with components.
941
+
942
+ Returns:
943
+ List of validation error messages.
944
+ """
945
+ errors: list[str] = []
946
+
947
+ if "components" not in schema:
948
+ errors.append("Form schema must have 'components' array")
949
+ return errors
950
+
951
+ components = schema["components"]
952
+ if not isinstance(components, list):
953
+ errors.append("'components' must be an array")
954
+ return errors
955
+
956
+ # Check for duplicate keys using list with duplicates preserved
957
+ all_keys = get_all_component_keys(components, include_duplicates=True)
958
+ seen: set[str] = set()
959
+ for key in all_keys:
960
+ if key in seen:
961
+ errors.append(f"Duplicate component key: '{key}'")
962
+ else:
963
+ seen.add(key)
964
+
965
+ # Validate each component
966
+ for i, comp in enumerate(components):
967
+ comp_errors = validate_component(comp)
968
+ for error in comp_errors:
969
+ errors.append(f"Component [{i}]: {error}")
970
+
971
+ return errors