Skip to content

Commit 57c91dd

Browse files
[Issue #6835] Add array_decomposition transform (#7070)
## Summary <!-- Use "Fixes" to automatically close issue upon PR merge. Use "Work for" when UAT is required. --> Fixes #6835 ## Changes proposed <!-- What was added, updated, or removed in this PR. --> Add a new `array_decomposition` transform option to `conditional_transformers.py` to process budget form data in accordance with SF424A XSD. Add configuration to sf424a Add tests ## Context for reviewers <!-- Technical or background context, more in-depth details of the implementation, and anything else you'd like reviewers to know We store the budget form data as an array where each line item contains the budget summary, budget categories, non federal resources and federal fund estimates. We need to support iterating through this array and pulling parts out of it to transform it. For more examples, info: see schema at https://apply07.grants.gov/apply/forms/schemas/SF424A-V1.0.xsd Note that full XSD validation won't work until the full XML config is configured for the SF424A. ## Validation steps <!-- Manual testing instructions, as well as any helpful references (screenshots, GIF demos, code examples or output). --> See new / updated unit tests. From the command line, create a JSON file like: ``` { "activity_line_items": [ { "activity_title": "Personnel Costs", "non_federal_resources": { "applicant_amount": "5000.00", "state_amount": "10000.00", "other_amount": "2500.00", "total_amount": "17500.00" } }, { "activity_title": "Equipment", "non_federal_resources": { "applicant_amount": "3000.00", "state_amount": "7500.00", "other_amount": "1500.00", "total_amount": "12000.00" } } ], "total_non_federal_resources": { "applicant_amount": "8000.00", "state_amount": "17500.00", "other_amount": "4000.00", "total_amount": "29500.00" } } ``` Then run: `docker compose run --rm grants-api poetry run flask task generate-xml --file sf424a.json --form SF424A --output sf424a_output.xml` And see XML file: ``` <?xml version='1.0' encoding='utf-8'?> <SF424A:BudgetInformation xmlns:SF424A="http://apply.grants.gov/forms/SF424A-V1.0" xmlns:glob="http://apply.grants.gov/system/Global-V1.0" SF424A:FormVersion="2.0"> <SF424A:BudgetSections> <SF424A:NonFederalResources> <SF424A:ResourceLineItem SF424A:activityTitle="Personnel Costs"> <SF424A:BudgetApplicantContributionAmount>5000.00</SF424A:BudgetApplicantContributionAmount> <SF424A:BudgetStateContributionAmount>10000.00</SF424A:BudgetStateContributionAmount> <SF424A:BudgetOtherContributionAmount>2500.00</SF424A:BudgetOtherContributionAmount> <SF424A:BudgetTotalContributionAmount>17500.00</SF424A:BudgetTotalContributionAmount> </SF424A:ResourceLineItem> <SF424A:ResourceLineItem SF424A:activityTitle="Equipment"> <SF424A:BudgetApplicantContributionAmount>3000.00</SF424A:BudgetApplicantContributionAmount> <SF424A:BudgetStateContributionAmount>7500.00</SF424A:BudgetStateContributionAmount> <SF424A:BudgetOtherContributionAmount>1500.00</SF424A:BudgetOtherContributionAmount> <SF424A:BudgetTotalContributionAmount>12000.00</SF424A:BudgetTotalContributionAmount> </SF424A:ResourceLineItem> <SF424A:ResourceTotals> <SF424A:BudgetApplicantContributionAmount>8000.00</SF424A:BudgetApplicantContributionAmount> <SF424A:BudgetStateContributionAmount>17500.00</SF424A:BudgetStateContributionAmount> <SF424A:BudgetOtherContributionAmount>4000.00</SF424A:BudgetOtherContributionAmount> <SF424A:BudgetTotalContributionAmount>29500.00</SF424A:BudgetTotalContributionAmount> </SF424A:ResourceTotals> </SF424A:NonFederalResources> </SF424A:BudgetSections> </SF424A:BudgetInformation>` ```
1 parent 10d5974 commit 57c91dd

File tree

9 files changed

+1697
-272
lines changed

9 files changed

+1697
-272
lines changed

api/src/cli/xml_generation_cli.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77

88
import click
99

10-
from src.form_schema.forms.sf424 import FORM_XML_TRANSFORM_RULES
10+
from src.form_schema.forms.sf424 import FORM_XML_TRANSFORM_RULES as SF424_TRANSFORM_RULES
11+
from src.form_schema.forms.sf424a import FORM_XML_TRANSFORM_RULES as SF424A_TRANSFORM_RULES
1112
from src.services.xml_generation.models import XMLGenerationRequest
1213
from src.services.xml_generation.service import XMLGenerationService
1314
from src.services.xml_generation.validation.test_cases import (
@@ -18,6 +19,12 @@
1819
from src.services.xml_generation.validation.xsd_fetcher import XSDFetcher
1920
from src.task.task_blueprint import task_blueprint
2021

22+
# Map form names to their transform rules
23+
FORM_TRANSFORM_RULES_MAP = {
24+
"SF424_4_0": SF424_TRANSFORM_RULES,
25+
"SF424A": SF424A_TRANSFORM_RULES,
26+
}
27+
2128

2229
@task_blueprint.cli.command("generate-xml")
2330
@click.option(
@@ -34,7 +41,7 @@
3441
@click.option(
3542
"--form",
3643
default="SF424_4_0",
37-
help="Form name/version (e.g., SF424_4_0). Default: SF424_4_0",
44+
help="Form name/version (e.g., SF424_4_0, SF424A). Default: SF424_4_0",
3845
)
3946
@click.option(
4047
"--compact",
@@ -60,11 +67,11 @@ def generate_xml_command(
6067
6168
Examples:
6269
63-
# Generate XML from JSON string
70+
# Generate XML from JSON string (SF-424)
6471
flask task generate-xml --json '{"field": "value"}' --form SF424_4_0
6572
66-
# Generate from file
67-
flask task generate-xml --file input.json --form SF424_4_0
73+
# Generate SF-424A from file
74+
flask task generate-xml --file input.json --form SF424A
6875
6976
# Generate compact XML and save to file
7077
flask task generate-xml --json '{"field": "value"}' --compact --output out.xml
@@ -80,11 +87,20 @@ def generate_xml_command(
8087
click.echo("Error: Must provide either --json or --file", err=True)
8188
sys.exit(1)
8289

90+
# Get transform config for the specified form
91+
transform_config = FORM_TRANSFORM_RULES_MAP.get(form)
92+
if not transform_config:
93+
click.echo(
94+
f"Error: Unknown form '{form}'. Available forms: {', '.join(FORM_TRANSFORM_RULES_MAP.keys())}",
95+
err=True,
96+
)
97+
sys.exit(1)
98+
8399
# Create service and generate XML
84100
service = XMLGenerationService()
85101
request = XMLGenerationRequest(
86102
application_data=application_data,
87-
transform_config=FORM_XML_TRANSFORM_RULES,
103+
transform_config=transform_config,
88104
pretty_print=not compact,
89105
)
90106

api/src/form_schema/forms/sf424a.py

Lines changed: 152 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -783,15 +783,68 @@
783783
"namespaces": {
784784
"default": "http://apply.grants.gov/forms/SF424A-V1.0",
785785
"SF424A": "http://apply.grants.gov/forms/SF424A-V1.0",
786+
"glob": "http://apply.grants.gov/system/Global-V1.0",
786787
},
787788
"xsd_url": "https://apply07.grants.gov/apply/forms/schemas/SF424A-V1.0.xsd",
788-
"xml_structure": {"root_element": "SF424A", "version": "2.0"},
789+
"xml_structure": {
790+
"root_element": "BudgetInformation",
791+
"root_namespace_prefix": "SF424A", # Use SF424A: prefix for root element per XSD
792+
"version": "2.0",
793+
# Required attributes for XSD validation
794+
"root_attributes": {
795+
"programType": "program_type", # Maps to input field
796+
"glob:coreSchemaVersion": "1.0", # Static value required by XSD
797+
},
798+
},
789799
"null_handling_options": {
790800
"exclude": "Default - exclude field entirely from XML (recommended)",
791801
"include_null": "Include empty XML element: <Field></Field>",
792802
"default_value": "Use configured default value when field is None",
793803
},
794804
},
805+
# Required first child element for XSD validation
806+
"form_version_identifier": {
807+
"xml_transform": {
808+
"target": "glob:FormVersionIdentifier",
809+
"namespace": "glob",
810+
}
811+
},
812+
# Program type - required root attribute (mapped via xml_structure.root_attributes)
813+
"program_type": {
814+
"xml_transform": {
815+
"target": "programType",
816+
"type": "attribute", # This will be handled as a root attribute
817+
}
818+
},
819+
# Activity title - appears as an attribute on line items
820+
"activity_title": {
821+
"xml_transform": {
822+
"target": "activityTitle",
823+
"type": "attribute",
824+
}
825+
},
826+
# Non-Federal Resources field mappings (Section C)
827+
# These map the internal field names to XSD element names
828+
"applicant_amount": {
829+
"xml_transform": {
830+
"target": "BudgetApplicantContributionAmount",
831+
}
832+
},
833+
"state_amount": {
834+
"xml_transform": {
835+
"target": "BudgetStateContributionAmount",
836+
}
837+
},
838+
"other_amount": {
839+
"xml_transform": {
840+
"target": "BudgetOtherContributionAmount",
841+
}
842+
},
843+
"total_amount": {
844+
"xml_transform": {
845+
"target": "BudgetTotalContributionAmount",
846+
}
847+
},
795848
# Forecasted Cash Needs - Section D
796849
# This requires pivoting the data structure from JSON to XML format
797850
"forecasted_cash_needs": {
@@ -831,6 +884,104 @@
831884
},
832885
}
833886
},
887+
# Budget sections decomposition
888+
# Transform row-oriented activity_line_items array to column-oriented arrays
889+
# organized by section type (budget_summary, budget_categories, etc.)
890+
#
891+
# Note: This transformation handles the data restructuring step. The XML generation
892+
# phase (not shown here) will handle:
893+
# - Adding activity_title as an XML attribute to each line item
894+
# - Using different XML element names for line items vs totals
895+
# (e.g., ResourceLineItem vs ResourceTotals)
896+
# - Proper XML namespace handling and element ordering
897+
#
898+
# Example transformation - this input structure:
899+
# {
900+
# "activity_line_items": [
901+
# {
902+
# "activity_title": "Activity 1",
903+
# "budget_summary": {"total_amount": "5000.00"},
904+
# "budget_categories": {"personnel_amount": "2000.00"},
905+
# "non_federal_resources": {"applicant_amount": "500.00"},
906+
# "federal_fund_estimates": {"first_year_amount": "5000.00"}
907+
# },
908+
# {
909+
# "activity_title": "Activity 2",
910+
# "budget_summary": {"total_amount": "8000.00"},
911+
# "budget_categories": {"personnel_amount": "3000.00"},
912+
# "non_federal_resources": {"applicant_amount": "1000.00"},
913+
# "federal_fund_estimates": {"first_year_amount": "8000.00"}
914+
# }
915+
# ],
916+
# "total_budget_summary": {"total_amount": "13000.00"},
917+
# "total_budget_categories": {"personnel_amount": "5000.00"},
918+
# "total_non_federal_resources": {"applicant_amount": "1500.00"},
919+
# "total_federal_fund_estimates": {"first_year_amount": "13000.00"}
920+
# }
921+
#
922+
# Becomes this output structure:
923+
# {
924+
# "BudgetSummaries": [
925+
# {"total_amount": "5000.00"}, # Activity 1
926+
# {"total_amount": "8000.00"}, # Activity 2
927+
# {"total_amount": "13000.00"} # Total
928+
# ],
929+
# "BudgetCategories": [
930+
# {"personnel_amount": "2000.00"}, # Activity 1
931+
# {"personnel_amount": "3000.00"}, # Activity 2
932+
# {"personnel_amount": "5000.00"} # Total
933+
# ],
934+
# "NonFederalResources": [
935+
# {"applicant_amount": "500.00"}, # Activity 1
936+
# {"applicant_amount": "1000.00"}, # Activity 2
937+
# {"applicant_amount": "1500.00"} # Total
938+
# ],
939+
# "FederalFundEstimates": [
940+
# {"first_year_amount": "5000.00"}, # Activity 1
941+
# {"first_year_amount": "8000.00"}, # Activity 2
942+
# {"first_year_amount": "13000.00"} # Total
943+
# ]
944+
# }
945+
"budget_sections": {
946+
"xml_transform": {
947+
"type": "conditional",
948+
"target": "BudgetSections",
949+
"conditional_transform": {
950+
"type": "array_decomposition",
951+
"source_array_field": "activity_line_items",
952+
"field_mappings": {
953+
"BudgetSummaries": {
954+
"item_field": "budget_summary",
955+
"item_wrapper": "SummaryLineItem",
956+
"item_attributes": ["activity_title"],
957+
"total_field": "total_budget_summary",
958+
"total_wrapper": "SummaryTotals",
959+
},
960+
"BudgetCategories": {
961+
"item_field": "budget_categories",
962+
"item_wrapper": "CategoryLineItem",
963+
"item_attributes": ["activity_title"],
964+
"total_field": "total_budget_categories",
965+
"total_wrapper": "CategoryTotals",
966+
},
967+
"NonFederalResources": {
968+
"item_field": "non_federal_resources",
969+
"item_wrapper": "ResourceLineItem",
970+
"item_attributes": ["activity_title"],
971+
"total_field": "total_non_federal_resources",
972+
"total_wrapper": "ResourceTotals",
973+
},
974+
"FederalFundEstimates": {
975+
"item_field": "federal_fund_estimates",
976+
"item_wrapper": "EstimateLineItem",
977+
"item_attributes": ["activity_title"],
978+
"total_field": "total_federal_fund_estimates",
979+
"total_wrapper": "EstimateTotals",
980+
},
981+
},
982+
},
983+
}
984+
},
834985
}
835986

836987
SF424a_v1_0 = Form(

0 commit comments

Comments
 (0)