Skip to content

Commit c4865cd

Browse files
[Issue #6898] Handle restructuring objects for nested fields (#7035)
## Summary <!-- Use "Fixes" to automatically close issue upon PR merge. Use "Work for" when UAT is required. --> Fixes #6898 ## Changes proposed <!-- What was added, updated, or removed in this PR. --> Add `_apply_pivot_object_transform` and associated `pivot_object` transform type to XML transformation logic. Add `FORM_XML_TRANSFORM_RULES` to sf424a form to test XML generation integration. Add tests to mirror real-world example. ## Context for reviewers <!-- Technical or background context, more in-depth details of the implementation, and anything else you'd like reviewers to know about that will help them understand the changes in the PR. --> Our JSON structure for `forecasted_cash_needs` and possibly others in the future differs slightly from the expected XML format. In XML, we need to pivot the JSON object to create the correct structure. Here we add the ability to do that in the JSON transform. It's possible the PR doesn't need us to add `FORM_XML_TRANSFORM_RULES`, but I figured we will need it anyway and best to add it for now. ## Validation steps <!-- Manual testing instructions, as well as any helpful references (screenshots, GIF demos, code examples or output). --> See new unit tests.
1 parent 83a5ef1 commit c4865cd

File tree

4 files changed

+782
-11
lines changed

4 files changed

+782
-11
lines changed

api/src/form_schema/forms/sf424a.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,66 @@
773773
},
774774
}
775775

776+
# XML Transformation Rules for SF-424A
777+
FORM_XML_TRANSFORM_RULES = {
778+
# Metadata
779+
"_xml_config": {
780+
"description": "XML transformation rules for converting Simpler SF-424A JSON to XML",
781+
"version": "1.0",
782+
"form_name": "SF424A",
783+
"namespaces": {
784+
"default": "http://apply.grants.gov/forms/SF424A-V1.0",
785+
"SF424A": "http://apply.grants.gov/forms/SF424A-V1.0",
786+
},
787+
"xsd_url": "https://apply07.grants.gov/apply/forms/schemas/SF424A-V1.0.xsd",
788+
"xml_structure": {"root_element": "SF424A", "version": "2.0"},
789+
"null_handling_options": {
790+
"exclude": "Default - exclude field entirely from XML (recommended)",
791+
"include_null": "Include empty XML element: <Field></Field>",
792+
"default_value": "Use configured default value when field is None",
793+
},
794+
},
795+
# Forecasted Cash Needs - Section D
796+
# This requires pivoting the data structure from JSON to XML format
797+
"forecasted_cash_needs": {
798+
"xml_transform": {
799+
"type": "conditional",
800+
"target": "BudgetForecastedCashNeeds",
801+
"conditional_transform": {
802+
"type": "pivot_object",
803+
"source_field": "forecasted_cash_needs",
804+
"field_mapping": {
805+
"BudgetFirstYearAmounts": {
806+
"BudgetFederalForecastedAmount": "federal_forecasted_cash_needs.total_amount",
807+
"BudgetNonFederalForecastedAmount": "non_federal_forecasted_cash_needs.total_amount",
808+
"BudgetTotalForecastedAmount": "total_forecasted_cash_needs.total_amount",
809+
},
810+
"BudgetFirstQuarterAmounts": {
811+
"BudgetFederalForecastedAmount": "federal_forecasted_cash_needs.first_quarter_amount",
812+
"BudgetNonFederalForecastedAmount": "non_federal_forecasted_cash_needs.first_quarter_amount",
813+
"BudgetTotalForecastedAmount": "total_forecasted_cash_needs.first_quarter_amount",
814+
},
815+
"BudgetSecondQuarterAmounts": {
816+
"BudgetFederalForecastedAmount": "federal_forecasted_cash_needs.second_quarter_amount",
817+
"BudgetNonFederalForecastedAmount": "non_federal_forecasted_cash_needs.second_quarter_amount",
818+
"BudgetTotalForecastedAmount": "total_forecasted_cash_needs.second_quarter_amount",
819+
},
820+
"BudgetThirdQuarterAmounts": {
821+
"BudgetFederalForecastedAmount": "federal_forecasted_cash_needs.third_quarter_amount",
822+
"BudgetNonFederalForecastedAmount": "non_federal_forecasted_cash_needs.third_quarter_amount",
823+
"BudgetTotalForecastedAmount": "total_forecasted_cash_needs.third_quarter_amount",
824+
},
825+
"BudgetFourthQuarterAmounts": {
826+
"BudgetFederalForecastedAmount": "federal_forecasted_cash_needs.fourth_quarter_amount",
827+
"BudgetNonFederalForecastedAmount": "non_federal_forecasted_cash_needs.fourth_quarter_amount",
828+
"BudgetTotalForecastedAmount": "total_forecasted_cash_needs.fourth_quarter_amount",
829+
},
830+
},
831+
},
832+
}
833+
},
834+
}
835+
776836
SF424a_v1_0 = Form(
777837
# https://grants.gov/forms/form-items-description/fid/241
778838
form_id=uuid.UUID("08e6603f-d197-4a60-98cd-d49acb1fc1fd"),

api/src/services/xml_generation/conditional_transformers.py

Lines changed: 82 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,65 @@
55
"""
66

77
import logging
8-
from typing import Any, cast
8+
from typing import Any
99

1010
from src.util.dict_util import get_nested_value
1111

1212
logger = logging.getLogger(__name__)
1313

1414

15+
def _apply_pivot_object_transform(
16+
transform_config: dict[str, Any], source_data: dict[str, Any]
17+
) -> dict[str, Any] | None:
18+
"""Apply pivot transformation to restructure nested objects."""
19+
source_field = transform_config.get("source_field")
20+
field_mapping = transform_config.get("field_mapping", {})
21+
22+
# Validate source_field is configured properly
23+
if not source_field or not isinstance(source_field, str):
24+
raise ConditionalTransformationError(
25+
"pivot_object requires 'source_field' to be a non-empty string"
26+
)
27+
28+
# Get the source object to pivot
29+
source_path = source_field.split(".")
30+
source_object = get_nested_value(source_data, source_path)
31+
32+
result = {}
33+
34+
for target_field, target_subfields in field_mapping.items():
35+
if not isinstance(target_subfields, dict):
36+
continue
37+
38+
# Build nested object for this target field
39+
nested_result = {}
40+
for target_subfield, source_path_str in target_subfields.items():
41+
# Parse the source path
42+
if not isinstance(source_path_str, str):
43+
continue
44+
45+
path_parts = source_path_str.split(".")
46+
value = source_object
47+
48+
# Navigate through the path
49+
for part in path_parts:
50+
if isinstance(value, dict) and part in value:
51+
value = value[part]
52+
else:
53+
value = None
54+
break
55+
56+
# Add value if found
57+
if value is not None:
58+
nested_result[target_subfield] = value
59+
60+
# Only add target field if we got at least one value
61+
if nested_result:
62+
result[target_field] = nested_result
63+
64+
return result if result else None
65+
66+
1567
class ConditionalTransformationError(Exception):
1668
"""Exception raised when conditional transformation fails."""
1769

@@ -44,22 +96,34 @@ def evaluate_condition(condition: dict[str, Any], source_data: dict[str, Any]) -
4496
if condition_type == "field_equals":
4597
field_path = condition.get("field")
4698
expected_value = condition.get("value")
47-
path_list = field_path.split(".") if isinstance(field_path, str) else (field_path or [])
48-
actual_value = get_nested_value(source_data, cast(list[str], path_list))
99+
if not field_path or not isinstance(field_path, str):
100+
raise ConditionalTransformationError(
101+
"field_equals condition requires 'field' to be a non-empty string"
102+
)
103+
path_list = field_path.split(".")
104+
actual_value = get_nested_value(source_data, path_list)
49105
return actual_value == expected_value
50106

51107
elif condition_type == "field_in":
52108
field_path = condition.get("field")
53109
allowed_values = condition.get("values", [])
54-
path_list = field_path.split(".") if isinstance(field_path, str) else (field_path or [])
55-
actual_value = get_nested_value(source_data, cast(list[str], path_list))
110+
if not field_path or not isinstance(field_path, str):
111+
raise ConditionalTransformationError(
112+
"field_in condition requires 'field' to be a non-empty string"
113+
)
114+
path_list = field_path.split(".")
115+
actual_value = get_nested_value(source_data, path_list)
56116
return actual_value in allowed_values
57117

58118
elif condition_type == "field_contains":
59119
field_path = condition.get("field")
60120
search_value = condition.get("value")
61-
path_list = field_path.split(".") if isinstance(field_path, str) else (field_path or [])
62-
actual_value = get_nested_value(source_data, cast(list[str], path_list))
121+
if not field_path or not isinstance(field_path, str):
122+
raise ConditionalTransformationError(
123+
"field_contains condition requires 'field' to be a non-empty string"
124+
)
125+
path_list = field_path.split(".")
126+
actual_value = get_nested_value(source_data, path_list)
63127
if isinstance(actual_value, list):
64128
return search_value in actual_value
65129
return False
@@ -89,6 +153,7 @@ def apply_conditional_transform(
89153
90154
Supports:
91155
- one_to_many: Map array field to multiple XML elements
156+
- pivot_object: Restructure nested objects by pivoting dimensions
92157
93158
Args:
94159
transform_config: Conditional transformation configuration
@@ -110,10 +175,13 @@ def apply_conditional_transform(
110175
max_count = transform_config.get("max_count", 10)
111176

112177
if source_field and target_pattern:
113-
source_path = (
114-
source_field.split(".") if isinstance(source_field, str) else (source_field or [])
115-
)
116-
source_values = get_nested_value(source_data, cast(list[str], source_path))
178+
# Validate source_field is a string
179+
if not isinstance(source_field, str):
180+
raise ConditionalTransformationError(
181+
"one_to_many requires 'source_field' to be a string"
182+
)
183+
source_path = source_field.split(".")
184+
source_values = get_nested_value(source_data, source_path)
117185

118186
if isinstance(source_values, list):
119187
result = {}
@@ -128,6 +196,9 @@ def apply_conditional_transform(
128196

129197
return None
130198

199+
elif transform_type == "pivot_object":
200+
return _apply_pivot_object_transform(transform_config, source_data)
201+
131202
else:
132203
raise ConditionalTransformationError(
133204
f"Unknown conditional transform type: {transform_type}"

0 commit comments

Comments
 (0)