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,991 @@
1
+ """YAML transformation engine for BPA service exports.
2
+
3
+ Transforms raw JSON exports from BPA into clean, human-readable YAML
4
+ suitable for AI consumption, version control, and documentation.
5
+
6
+ Key transformations:
7
+ - Parse stringified JSON (formSchema, jsonDeterminants)
8
+ - Convert camelCase to snake_case
9
+ - Extract essential Form.io properties
10
+ - Preserve metadata for re-import
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import re
17
+ from datetime import UTC, datetime
18
+ from typing import Any
19
+
20
+ import yaml
21
+
22
+ __all__ = [
23
+ "YAMLTransformer",
24
+ "TransformationError",
25
+ "normalize_export_data",
26
+ ]
27
+
28
+ # Schema version for the YAML output
29
+ SCHEMA_VERSION = "1.0"
30
+
31
+ # Form.io properties to extract (essential for understanding the form)
32
+ ESSENTIAL_FORMIO_PROPS = {
33
+ "key",
34
+ "type",
35
+ "label",
36
+ "validate",
37
+ "data",
38
+ "determinantIds",
39
+ "hidden",
40
+ "disabled",
41
+ "defaultValue",
42
+ "multiple",
43
+ "conditional",
44
+ }
45
+
46
+ # Form.io properties to skip (internal/noise)
47
+ SKIP_FORMIO_PROPS = {
48
+ "tableView",
49
+ "input",
50
+ "version",
51
+ "persistent",
52
+ "customDefaultValue",
53
+ "calculateValue",
54
+ "calculateServer",
55
+ "widget",
56
+ "attributes",
57
+ "overlay",
58
+ "allowCalculateOverride",
59
+ "encrypted",
60
+ "showCharCount",
61
+ "showWordCount",
62
+ "spellcheck",
63
+ "redrawOn",
64
+ "clearOnHide",
65
+ "modalEdit",
66
+ "refreshOn",
67
+ "dataGridLabel",
68
+ "allowMultipleMasks",
69
+ "addons",
70
+ "mask",
71
+ "inputType",
72
+ "inputFormat",
73
+ "inputMask",
74
+ "displayMask",
75
+ "tabindex",
76
+ "autocomplete",
77
+ "dbIndex",
78
+ "customClass",
79
+ "id",
80
+ "placeholder",
81
+ "prefix",
82
+ "suffix",
83
+ "tooltip",
84
+ "description",
85
+ "errorLabel",
86
+ "hideLabel",
87
+ "autofocus",
88
+ "kickbox",
89
+ "minLength",
90
+ "maxLength",
91
+ "delimiter",
92
+ "requireDecimal",
93
+ "case",
94
+ "truncateMultipleSpaces",
95
+ }
96
+
97
+
98
+ class TransformationError(Exception):
99
+ """Raised when YAML transformation fails."""
100
+
101
+ pass
102
+
103
+
104
+ def camel_to_snake(name: str) -> str:
105
+ """Convert camelCase to snake_case.
106
+
107
+ Args:
108
+ name: The camelCase string to convert.
109
+
110
+ Returns:
111
+ The snake_case equivalent.
112
+
113
+ Examples:
114
+ >>> camel_to_snake("shortName")
115
+ 'short_name'
116
+ >>> camel_to_snake("applicantFormSelected")
117
+ 'applicant_form_selected'
118
+ """
119
+ # Insert underscore before uppercase letters and convert to lowercase
120
+ s1 = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", name)
121
+ return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
122
+
123
+
124
+ def transform_keys(obj: Any, skip_keys: set[str] | None = None) -> Any:
125
+ """Recursively transform dictionary keys from camelCase to snake_case.
126
+
127
+ Args:
128
+ obj: The object to transform (dict, list, or primitive).
129
+ skip_keys: Set of keys to skip transformation for.
130
+
131
+ Returns:
132
+ The transformed object with snake_case keys.
133
+ """
134
+ skip_keys = skip_keys or set()
135
+
136
+ if isinstance(obj, dict):
137
+ return {
138
+ (camel_to_snake(k) if k not in skip_keys else k): transform_keys(
139
+ v, skip_keys
140
+ )
141
+ for k, v in obj.items()
142
+ }
143
+ elif isinstance(obj, list):
144
+ return [transform_keys(item, skip_keys) for item in obj]
145
+ else:
146
+ return obj
147
+
148
+
149
+ def normalize_export_data(data: dict[str, Any]) -> dict[str, Any]:
150
+ """Normalize BPA export data to a consistent flat structure.
151
+
152
+ The live BPA API returns data with different structure than test mocks:
153
+ - Live API: {"appVersion": ..., "service": {"name": ..., ...}, "catalogs": [...]}
154
+ - Test mocks: {"name": ..., "registrations": [...], ...}
155
+
156
+ This function unwraps nested structures and remaps field names.
157
+
158
+ Args:
159
+ data: Raw export data from BPA API or test mock.
160
+
161
+ Returns:
162
+ Normalized data with consistent flat structure.
163
+ """
164
+ # If data is already flat (has "name" at root), return as-is
165
+ if "name" in data and "service" not in data:
166
+ return data
167
+
168
+ # If data has nested "service" key, extract and merge
169
+ if "service" in data and isinstance(data["service"], dict):
170
+ service_data = data["service"].copy()
171
+
172
+ # Remap live API field names to expected names
173
+ field_mappings = {
174
+ "serviceDeterminants": "determinants",
175
+ "applicantFormPage": "applicantForm",
176
+ "guideFormPage": "guideForm",
177
+ "sendFileFormPage": "sendFileForm",
178
+ "paymentFormPage": "paymentForm",
179
+ }
180
+
181
+ for old_key, new_key in field_mappings.items():
182
+ if old_key in service_data:
183
+ service_data[new_key] = service_data.pop(old_key)
184
+
185
+ # Parse stringified formSchema in forms if needed
186
+ for form_key in ["applicantForm", "guideForm", "sendFileForm", "paymentForm"]:
187
+ form = service_data.get(form_key)
188
+ if form and isinstance(form, dict):
189
+ form_schema = form.get("formSchema")
190
+ if isinstance(form_schema, str):
191
+ try:
192
+ form["formSchema"] = json.loads(form_schema)
193
+ except json.JSONDecodeError:
194
+ pass # Keep as string if parsing fails
195
+
196
+ # Merge catalogs from root level if present
197
+ if "catalogs" in data:
198
+ service_data["catalogs"] = data["catalogs"]
199
+
200
+ # Preserve app version at root level
201
+ if "appVersion" in data:
202
+ service_data["appVersion"] = data["appVersion"]
203
+
204
+ return service_data
205
+
206
+ return data
207
+
208
+
209
+ class YAMLTransformer:
210
+ """Transforms BPA JSON exports to clean YAML format."""
211
+
212
+ def __init__(self, include_metadata: bool = True) -> None:
213
+ """Initialize the transformer.
214
+
215
+ Args:
216
+ include_metadata: Whether to include _metadata section with IDs
217
+ for re-import capability. Default True.
218
+ """
219
+ self._include_metadata = include_metadata
220
+ self._metadata: dict[str, Any] = {}
221
+
222
+ def transform_to_dict(
223
+ self, export_data: dict[str, Any]
224
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
225
+ """Transform raw JSON export to optimized dictionary structure.
226
+
227
+ This method builds the clean, optimized data structure without
228
+ serializing it. Use this when you need the data as a Python dict
229
+ (e.g., for JSON serialization).
230
+
231
+ Args:
232
+ export_data: Raw JSON export from service_export_raw.
233
+
234
+ Returns:
235
+ Tuple of (data_dict, summary).
236
+
237
+ Raises:
238
+ TransformationError: If transformation fails.
239
+ """
240
+ try:
241
+ # Build the optimized structure
242
+ data_dict = self._build_yaml_structure(export_data)
243
+
244
+ # Generate summary (extract from data since it's now in the structure)
245
+ summary = data_dict.get("summary", {}).copy()
246
+ if not summary:
247
+ summary = self._generate_summary(data_dict)
248
+
249
+ return data_dict, summary
250
+
251
+ except Exception as e:
252
+ raise TransformationError(f"Failed to transform export: {e}") from e
253
+
254
+ def transform(self, export_data: dict[str, Any]) -> tuple[str, dict[str, Any]]:
255
+ """Transform raw JSON export to YAML.
256
+
257
+ Args:
258
+ export_data: Raw JSON export from service_export_raw.
259
+
260
+ Returns:
261
+ Tuple of (yaml_content, summary).
262
+
263
+ Raises:
264
+ TransformationError: If transformation fails.
265
+ """
266
+ try:
267
+ # Build the optimized structure
268
+ yaml_data, summary = self.transform_to_dict(export_data)
269
+
270
+ # Convert to YAML string
271
+ yaml_content = yaml.dump(
272
+ yaml_data,
273
+ default_flow_style=False,
274
+ allow_unicode=True,
275
+ sort_keys=False,
276
+ width=120,
277
+ )
278
+
279
+ return yaml_content, summary
280
+
281
+ except Exception as e:
282
+ raise TransformationError(f"Failed to transform export: {e}") from e
283
+
284
+ def _build_yaml_structure(self, data: dict[str, Any]) -> dict[str, Any]:
285
+ """Build the complete YAML structure.
286
+
287
+ Args:
288
+ data: Raw export data.
289
+
290
+ Returns:
291
+ Structured dictionary for YAML output.
292
+ """
293
+ # Normalize export data to handle both live API and test mock structures
294
+ data = normalize_export_data(data)
295
+
296
+ # Reset metadata for this transformation
297
+ self._metadata = {
298
+ "service_id": data.get("id"),
299
+ "service_old_id": data.get("oldId"),
300
+ "registration_ids": {},
301
+ "determinant_ids": {},
302
+ "role_ids": {},
303
+ "bot_ids": {},
304
+ }
305
+
306
+ result: dict[str, Any] = {
307
+ "version": SCHEMA_VERSION,
308
+ "exported_at": datetime.now(UTC).isoformat(),
309
+ }
310
+
311
+ # Add BPA version if available
312
+ if "bpaVersion" in data:
313
+ result["bpa_version"] = data["bpaVersion"]
314
+
315
+ # Build all sections first to calculate summary
316
+ service_section = self._build_service_section(data)
317
+ registrations = data.get("registrations", [])
318
+ registrations_section = (
319
+ self._build_registrations_section(registrations) if registrations else []
320
+ )
321
+ determinants = data.get("determinants", [])
322
+ determinants_section = (
323
+ self._build_determinants_section(determinants) if determinants else {}
324
+ )
325
+ forms_section = self._build_forms_section(data)
326
+ roles = data.get("roles", [])
327
+ workflow_section = self._build_workflow_section(roles) if roles else {}
328
+ bots = data.get("bots", [])
329
+ bots_section = self._build_bots_section(bots) if bots else {}
330
+ print_docs = data.get("printDocuments", [])
331
+ print_docs_section = (
332
+ self._build_print_documents_section(print_docs) if print_docs else []
333
+ )
334
+
335
+ # Add summary section at the top (after version/exported_at)
336
+ result["summary"] = self._build_summary_section(
337
+ service_section,
338
+ registrations_section,
339
+ determinants_section,
340
+ forms_section,
341
+ workflow_section,
342
+ bots_section,
343
+ )
344
+
345
+ # Build service section
346
+ result["service"] = service_section
347
+
348
+ # Add pre-built sections (only if they have content)
349
+ if registrations_section:
350
+ result["registrations"] = registrations_section
351
+
352
+ if determinants_section:
353
+ result["determinants"] = determinants_section
354
+
355
+ if forms_section:
356
+ result["forms"] = forms_section
357
+
358
+ if workflow_section and workflow_section.get("roles"):
359
+ result["workflow"] = workflow_section
360
+
361
+ if bots_section:
362
+ result["bots"] = bots_section
363
+
364
+ if print_docs_section:
365
+ result["print_documents"] = print_docs_section
366
+
367
+ # Add metadata section if requested
368
+ if self._include_metadata:
369
+ result["_metadata"] = self._metadata
370
+
371
+ return result
372
+
373
+ def _build_summary_section(
374
+ self,
375
+ service_section: dict[str, Any],
376
+ registrations_section: list[dict[str, Any]],
377
+ determinants_section: dict[str, Any],
378
+ forms_section: dict[str, Any],
379
+ workflow_section: dict[str, Any],
380
+ bots_section: dict[str, Any],
381
+ ) -> dict[str, Any]:
382
+ """Build the summary section for quick scanning.
383
+
384
+ Args:
385
+ service_section: Built service section.
386
+ registrations_section: Built registrations list.
387
+ determinants_section: Built determinants dictionary.
388
+ forms_section: Built forms dictionary.
389
+ workflow_section: Built workflow dictionary.
390
+ bots_section: Built bots dictionary.
391
+
392
+ Returns:
393
+ Summary dictionary with counts.
394
+ """
395
+ summary: dict[str, Any] = {
396
+ "service_name": service_section.get("name", "Unknown"),
397
+ }
398
+
399
+ # Count registrations
400
+ summary["registration_count"] = len(registrations_section)
401
+
402
+ # Count determinants
403
+ det_count = 0
404
+ for category in determinants_section.values():
405
+ if isinstance(category, list):
406
+ det_count += len(category)
407
+ summary["determinant_count"] = det_count
408
+
409
+ # Count form fields
410
+ field_count = 0
411
+ for form in forms_section.values():
412
+ if isinstance(form, dict):
413
+ components = form.get("components", [])
414
+ field_count += self._count_components(components)
415
+ summary["field_count"] = field_count
416
+
417
+ # Count roles
418
+ roles = workflow_section.get("roles", [])
419
+ summary["role_count"] = len(roles)
420
+
421
+ # Count bots
422
+ bot_count = 0
423
+ for category in bots_section.values():
424
+ if isinstance(category, list):
425
+ bot_count += len(category)
426
+ summary["bot_count"] = bot_count
427
+
428
+ return summary
429
+
430
+ def _build_service_section(self, data: dict[str, Any]) -> dict[str, Any]:
431
+ """Build the service section.
432
+
433
+ Args:
434
+ data: Raw export data.
435
+
436
+ Returns:
437
+ Service section dictionary.
438
+ """
439
+ service: dict[str, Any] = {
440
+ "name": data.get("name", "Unknown"),
441
+ "short_name": data.get("shortName", ""),
442
+ "active": data.get("active", True),
443
+ }
444
+
445
+ # Extract service properties from serviceJsonProperties
446
+ json_props = data.get("serviceJsonProperties")
447
+ if json_props:
448
+ if isinstance(json_props, str):
449
+ try:
450
+ json_props = json.loads(json_props)
451
+ except json.JSONDecodeError:
452
+ json_props = {}
453
+
454
+ if isinstance(json_props, dict):
455
+ service["properties"] = transform_keys(json_props)
456
+
457
+ return service
458
+
459
+ def _build_registrations_section(
460
+ self, registrations: list[dict[str, Any]]
461
+ ) -> list[dict[str, Any]]:
462
+ """Build the registrations section.
463
+
464
+ Args:
465
+ registrations: List of registration objects.
466
+
467
+ Returns:
468
+ Transformed registrations list.
469
+ """
470
+ result = []
471
+
472
+ for reg in registrations:
473
+ name = reg.get("name", "Unknown")
474
+
475
+ # Store ID mapping
476
+ if reg.get("id"):
477
+ self._metadata["registration_ids"][name] = reg["id"]
478
+
479
+ transformed = {
480
+ "name": name,
481
+ "short_name": reg.get("shortName", ""),
482
+ "active": reg.get("active", True),
483
+ }
484
+
485
+ # Add costs if present
486
+ costs = reg.get("costs", [])
487
+ if costs:
488
+ transformed["costs"] = self._transform_costs(costs)
489
+
490
+ # Add document requirements if present
491
+ doc_reqs = reg.get("documentRequirements", [])
492
+ if doc_reqs:
493
+ transformed["document_requirements"] = [
494
+ {"name": dr.get("name", ""), "required": dr.get("required", True)}
495
+ for dr in doc_reqs
496
+ ]
497
+
498
+ # Add document results if present
499
+ doc_results = reg.get("documentResults", [])
500
+ if doc_results:
501
+ transformed["document_results"] = [
502
+ {
503
+ "name": dr.get("name", ""),
504
+ "is_digital": dr.get("isDigital", False),
505
+ }
506
+ for dr in doc_results
507
+ ]
508
+
509
+ result.append(transformed)
510
+
511
+ return result
512
+
513
+ def _transform_costs(self, costs: list[dict[str, Any]]) -> list[dict[str, Any]]:
514
+ """Transform cost objects.
515
+
516
+ Args:
517
+ costs: List of cost objects.
518
+
519
+ Returns:
520
+ Transformed costs list.
521
+ """
522
+ result = []
523
+
524
+ for cost in costs:
525
+ transformed: dict[str, Any] = {
526
+ "name": cost.get("name", ""),
527
+ }
528
+
529
+ # Determine cost type
530
+ cost_type = cost.get("costType", "").lower()
531
+ if cost_type == "fix":
532
+ transformed["type"] = "fixed"
533
+ transformed["amount"] = cost.get("fixValue", 0)
534
+ elif cost_type == "formula":
535
+ transformed["type"] = "formula"
536
+ transformed["formula"] = cost.get("formula", "")
537
+ else:
538
+ transformed["type"] = cost_type or "unknown"
539
+
540
+ # Add currency if present
541
+ currency = cost.get("currency") or cost.get("currencyId")
542
+ if currency:
543
+ transformed["currency"] = currency
544
+
545
+ result.append(transformed)
546
+
547
+ return result
548
+
549
+ def _build_determinants_section(
550
+ self, determinants: list[dict[str, Any]]
551
+ ) -> dict[str, Any]:
552
+ """Build the determinants section.
553
+
554
+ Args:
555
+ determinants: List of determinant objects.
556
+
557
+ Returns:
558
+ Categorized determinants dictionary.
559
+ """
560
+ form_field_based = []
561
+ registration_based = []
562
+ other = []
563
+
564
+ for det in determinants:
565
+ name = det.get("name", "Unknown")
566
+
567
+ # Store ID mapping
568
+ if det.get("id"):
569
+ self._metadata["determinant_ids"][name] = det["id"]
570
+
571
+ det_type = det.get("type", "").lower()
572
+
573
+ transformed: dict[str, Any] = {
574
+ "name": name,
575
+ }
576
+
577
+ if det_type in ("textdeterminant", "text"):
578
+ transformed["field"] = det.get("targetFormFieldKey", "")
579
+ transformed["operator"] = det.get("operator", "equals")
580
+ # Parse condition value from jsonCondition if available
581
+ json_cond = det.get("jsonCondition")
582
+ if json_cond:
583
+ transformed["value"] = self._parse_condition_value(json_cond)
584
+ form_field_based.append(transformed)
585
+
586
+ elif det_type in ("selectdeterminant", "select"):
587
+ transformed["field"] = det.get("targetFormFieldKey", "")
588
+ transformed["operator"] = det.get("operator", "equals")
589
+ transformed["value"] = det.get("selectValue", "")
590
+ form_field_based.append(transformed)
591
+
592
+ elif det_type in ("registrationdeterminant", "registration"):
593
+ transformed["registration"] = det.get("registrationName", "")
594
+ transformed["selected"] = det.get("selected", True)
595
+ registration_based.append(transformed)
596
+
597
+ else:
598
+ transformed["type"] = det_type
599
+ other.append(transformed)
600
+
601
+ result: dict[str, Any] = {}
602
+
603
+ if form_field_based:
604
+ result["form_field_based"] = form_field_based
605
+
606
+ if registration_based:
607
+ result["registration_based"] = registration_based
608
+
609
+ if other:
610
+ result["other"] = other
611
+
612
+ return result
613
+
614
+ def _parse_condition_value(self, json_condition: str | dict[str, Any]) -> Any:
615
+ """Parse condition value from jsonCondition.
616
+
617
+ Args:
618
+ json_condition: JSON condition string or dict.
619
+
620
+ Returns:
621
+ Extracted condition value.
622
+ """
623
+ if isinstance(json_condition, str):
624
+ try:
625
+ json_condition = json.loads(json_condition)
626
+ except json.JSONDecodeError:
627
+ return json_condition
628
+
629
+ if isinstance(json_condition, dict):
630
+ # Extract value from common patterns
631
+ return json_condition.get("value", json_condition)
632
+
633
+ return json_condition
634
+
635
+ def _build_forms_section(self, data: dict[str, Any]) -> dict[str, Any]:
636
+ """Build the forms section.
637
+
638
+ Args:
639
+ data: Raw export data.
640
+
641
+ Returns:
642
+ Forms section dictionary.
643
+ """
644
+ forms: dict[str, Any] = {}
645
+
646
+ form_keys = [
647
+ ("guideForm", "guide"),
648
+ ("applicantForm", "applicant"),
649
+ ("sendFileForm", "send_file"),
650
+ ("paymentForm", "payment"),
651
+ ]
652
+
653
+ for json_key, yaml_key in form_keys:
654
+ form_data = data.get(json_key)
655
+ if form_data:
656
+ parsed = self._parse_form(form_data)
657
+ if parsed:
658
+ forms[yaml_key] = parsed
659
+
660
+ return forms
661
+
662
+ def _parse_form(self, form_data: dict[str, Any]) -> dict[str, Any] | None:
663
+ """Parse a form object.
664
+
665
+ Args:
666
+ form_data: Form data from export.
667
+
668
+ Returns:
669
+ Parsed form dictionary or None.
670
+ """
671
+ if not form_data:
672
+ return None
673
+
674
+ result: dict[str, Any] = {
675
+ "active": form_data.get("active", True),
676
+ }
677
+
678
+ # Parse form schema
679
+ form_schema = form_data.get("formSchema")
680
+ if form_schema:
681
+ components = self._parse_form_schema(form_schema)
682
+ if components:
683
+ result["components"] = components
684
+
685
+ return result
686
+
687
+ def _parse_form_schema(
688
+ self, schema: str | dict[str, Any]
689
+ ) -> list[dict[str, Any]] | None:
690
+ """Parse Form.io schema (may be stringified JSON).
691
+
692
+ Args:
693
+ schema: Form schema (string or dict).
694
+
695
+ Returns:
696
+ List of parsed components.
697
+ """
698
+ if isinstance(schema, str):
699
+ try:
700
+ schema = json.loads(schema)
701
+ except json.JSONDecodeError:
702
+ return None
703
+
704
+ if not isinstance(schema, dict):
705
+ return None
706
+
707
+ components = schema.get("components", [])
708
+ if not components:
709
+ return None
710
+
711
+ return self._extract_components(components)
712
+
713
+ def _extract_components(
714
+ self, components: list[dict[str, Any]]
715
+ ) -> list[dict[str, Any]]:
716
+ """Extract essential properties from Form.io components.
717
+
718
+ Args:
719
+ components: List of Form.io components.
720
+
721
+ Returns:
722
+ Simplified component list.
723
+ """
724
+ result = []
725
+
726
+ for comp in components:
727
+ if not isinstance(comp, dict):
728
+ continue
729
+
730
+ # Skip components without a key
731
+ key = comp.get("key")
732
+ if not key:
733
+ continue
734
+
735
+ extracted: dict[str, Any] = {
736
+ "key": key,
737
+ "type": comp.get("type", "unknown"),
738
+ }
739
+
740
+ # Add label if present
741
+ label = comp.get("label")
742
+ if label:
743
+ extracted["label"] = label
744
+
745
+ # Add required if true
746
+ validate = comp.get("validate", {})
747
+ if isinstance(validate, dict) and validate.get("required"):
748
+ extracted["required"] = True
749
+
750
+ # Add data source info for selects
751
+ data = comp.get("data", {})
752
+ if isinstance(data, dict):
753
+ if data.get("dataSrc"):
754
+ extracted["data_source"] = data["dataSrc"]
755
+ if data.get("catalog"):
756
+ extracted["catalog"] = data["catalog"]
757
+
758
+ # Add determinant IDs if present
759
+ det_ids = comp.get("determinantIds")
760
+ if det_ids:
761
+ extracted["determinant_ids"] = det_ids
762
+
763
+ # Add hidden/disabled if true
764
+ if comp.get("hidden"):
765
+ extracted["hidden"] = True
766
+ if comp.get("disabled"):
767
+ extracted["disabled"] = True
768
+
769
+ # Handle nested components (panels, columns, etc.)
770
+ nested = comp.get("components", [])
771
+ if nested:
772
+ nested_extracted = self._extract_components(nested)
773
+ if nested_extracted:
774
+ extracted["components"] = nested_extracted
775
+
776
+ # Handle columns
777
+ columns = comp.get("columns", [])
778
+ if columns:
779
+ col_components = []
780
+ for col in columns:
781
+ if isinstance(col, dict):
782
+ col_nested = col.get("components", [])
783
+ if col_nested:
784
+ col_components.extend(self._extract_components(col_nested))
785
+ if col_components:
786
+ extracted["components"] = col_components
787
+
788
+ # Handle tabs
789
+ tabs = comp.get("tabs", [])
790
+ if tabs:
791
+ extracted["tabs"] = [
792
+ {"label": t.get("label", ""), "key": t.get("key", "")}
793
+ for t in tabs
794
+ if isinstance(t, dict)
795
+ ]
796
+
797
+ result.append(extracted)
798
+
799
+ return result
800
+
801
+ def _build_workflow_section(self, roles: list[dict[str, Any]]) -> dict[str, Any]:
802
+ """Build the workflow section from roles.
803
+
804
+ Args:
805
+ roles: List of role objects.
806
+
807
+ Returns:
808
+ Workflow section dictionary.
809
+ """
810
+ result: dict[str, Any] = {"roles": []}
811
+
812
+ for role in roles:
813
+ name = role.get("name", "Unknown")
814
+
815
+ # Store ID mapping
816
+ if role.get("id"):
817
+ self._metadata["role_ids"][name] = role["id"]
818
+
819
+ transformed: dict[str, Any] = {
820
+ "name": name,
821
+ "short_name": role.get("shortName", ""),
822
+ }
823
+
824
+ # Add role type
825
+ role_type = role.get("type", "")
826
+ if role_type:
827
+ transformed["type"] = role_type
828
+
829
+ # Add start role flag
830
+ if role.get("startRole"):
831
+ transformed["start_role"] = True
832
+
833
+ # Add visible to applicant
834
+ if role.get("visibleToApplicant"):
835
+ transformed["visible_to_applicant"] = True
836
+
837
+ # Add statuses/destinations if present
838
+ statuses = role.get("statuses", [])
839
+ if statuses:
840
+ transformed["statuses"] = [
841
+ {
842
+ "name": s.get("name", ""),
843
+ "type": s.get("type", "status"),
844
+ }
845
+ for s in statuses
846
+ if isinstance(s, dict)
847
+ ]
848
+
849
+ result["roles"].append(transformed)
850
+
851
+ return result
852
+
853
+ def _build_bots_section(self, bots: list[dict[str, Any]]) -> dict[str, Any]:
854
+ """Build the bots section.
855
+
856
+ Args:
857
+ bots: List of bot objects.
858
+
859
+ Returns:
860
+ Categorized bots dictionary.
861
+ """
862
+ documents = []
863
+ data_bots = []
864
+ other = []
865
+
866
+ for bot in bots:
867
+ name = bot.get("name", "Unknown")
868
+
869
+ # Store ID mapping
870
+ if bot.get("id"):
871
+ self._metadata["bot_ids"][name] = bot["id"]
872
+
873
+ transformed: dict[str, Any] = {
874
+ "name": name,
875
+ "short_name": bot.get("shortName", ""),
876
+ }
877
+
878
+ # Add enabled status
879
+ if not bot.get("enabled", True):
880
+ transformed["enabled"] = False
881
+
882
+ bot_type = bot.get("botType", "").lower()
883
+
884
+ if "document" in bot_type or "upload" in bot_type:
885
+ transformed["category"] = "document"
886
+ documents.append(transformed)
887
+ elif "data" in bot_type or "fetch" in bot_type:
888
+ transformed["category"] = "data"
889
+ data_bots.append(transformed)
890
+ else:
891
+ transformed["category"] = bot_type or "other"
892
+ other.append(transformed)
893
+
894
+ result: dict[str, Any] = {}
895
+
896
+ if documents:
897
+ result["documents"] = documents
898
+ if data_bots:
899
+ result["data"] = data_bots
900
+ if other:
901
+ result["other"] = other
902
+
903
+ return result
904
+
905
+ def _build_print_documents_section(
906
+ self, print_docs: list[dict[str, Any]]
907
+ ) -> list[dict[str, Any]]:
908
+ """Build the print documents section.
909
+
910
+ Args:
911
+ print_docs: List of print document objects.
912
+
913
+ Returns:
914
+ Transformed print documents list.
915
+ """
916
+ return [
917
+ {
918
+ "name": doc.get("name", ""),
919
+ "short_name": doc.get("shortName", ""),
920
+ "active": doc.get("active", True),
921
+ }
922
+ for doc in print_docs
923
+ ]
924
+
925
+ def _generate_summary(self, yaml_data: dict[str, Any]) -> dict[str, Any]:
926
+ """Generate summary statistics for the transformation.
927
+
928
+ Args:
929
+ yaml_data: The transformed YAML data.
930
+
931
+ Returns:
932
+ Summary dictionary.
933
+ """
934
+ summary: dict[str, Any] = {
935
+ "service_name": yaml_data.get("service", {}).get("name", "Unknown"),
936
+ "version": yaml_data.get("version", SCHEMA_VERSION),
937
+ }
938
+
939
+ # Count registrations
940
+ registrations = yaml_data.get("registrations", [])
941
+ summary["registration_count"] = len(registrations)
942
+
943
+ # Count determinants
944
+ determinants = yaml_data.get("determinants", {})
945
+ det_count = 0
946
+ for category in determinants.values():
947
+ if isinstance(category, list):
948
+ det_count += len(category)
949
+ summary["determinant_count"] = det_count
950
+
951
+ # Count form fields
952
+ forms = yaml_data.get("forms", {})
953
+ field_count = 0
954
+ for form in forms.values():
955
+ if isinstance(form, dict):
956
+ components = form.get("components", [])
957
+ field_count += self._count_components(components)
958
+ summary["field_count"] = field_count
959
+
960
+ # Count roles
961
+ workflow = yaml_data.get("workflow", {})
962
+ roles = workflow.get("roles", [])
963
+ summary["role_count"] = len(roles)
964
+
965
+ # Count bots
966
+ bots = yaml_data.get("bots", {})
967
+ bot_count = 0
968
+ for category in bots.values():
969
+ if isinstance(category, list):
970
+ bot_count += len(category)
971
+ summary["bot_count"] = bot_count
972
+
973
+ return summary
974
+
975
+ def _count_components(self, components: list[dict[str, Any]]) -> int:
976
+ """Recursively count form components.
977
+
978
+ Args:
979
+ components: List of components.
980
+
981
+ Returns:
982
+ Total component count.
983
+ """
984
+ count = 0
985
+ for comp in components:
986
+ if isinstance(comp, dict):
987
+ count += 1
988
+ nested = comp.get("components", [])
989
+ if nested:
990
+ count += self._count_components(nested)
991
+ return count