Some checks failed
CI / skinny-install (aco) (push) Successful in 45s
CI / skinny-install (api) (push) Successful in 29s
CI / skinny-install (bcda) (push) Successful in 25s
CI / skinny-install (bib) (push) Successful in 23s
CI / skinny-install (bls) (push) Successful in 20s
CI / skinny-install (ccw) (push) Successful in 36s
CI / skinny-install (cli) (push) Successful in 27s
CI / skinny-install (cms) (push) Successful in 24s
CI / skinny-install (conf) (push) Successful in 27s
CI / skinny-install (pfs) (push) Successful in 25s
CI / skinny-install (rex) (push) Successful in 25s
CI / lint-test (push) Successful in 6m2s
Infra CI / notebooks (push) Successful in 7s
Infra CI / zotero (push) Failing after 6s
Infra CI / docs (push) Successful in 33s
Infra CI / api (push) Successful in 6s
Infra CI / mc (push) Successful in 7s
Deploy / build-scan-report (push) Has been cancelled
- Fix all 72 ruff lint errors (unused imports, unused variables, E402) - Format all 14 unformatted dev/scripts files - Move generated artifacts to assets/ (dag.html, pfs.html) - Remove duplicate root coverage.svg (already in assets/icons/) - Update .dockerignore for infra/ tree layout - Update .gitignore: add .env.bak, mirrors/, htmlcov/ - Fix stale path refs in coverage_badge.py, woodpecker backend, test_network_isolation.sh, docs custom.css - Add .gitkeep to empty dirs (infra/polaris, cloud/*/terraform) - Delete 12 stale local branches, 10 stale remote branches
368 lines
14 KiB
Python
368 lines
14 KiB
Python
"""Generate ACO REACH Participants table model and rex parser.
|
|
|
|
Extracts the participant provider schema from the bulk upload template Excel file
|
|
and generates a SQLTable model plus rex parser.
|
|
|
|
Source: dev/ACO_REACH_bulk_upload_participants 5-19 (1).xlsx
|
|
|
|
Usage::
|
|
|
|
uv run python dev/scripts/generate_reach_participants.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import zipfile
|
|
from pathlib import Path
|
|
from xml.etree import ElementTree as ET
|
|
|
|
|
|
def extract_headers_from_template(xlsx_path: str) -> list[str]:
|
|
"""Extract column headers from bulk upload template."""
|
|
with zipfile.ZipFile(xlsx_path, "r") as zip_ref:
|
|
# Read shared strings
|
|
strings_xml = zip_ref.read("xl/sharedStrings.xml")
|
|
root = ET.fromstring(strings_xml)
|
|
ns_ss = {"ss": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
|
|
shared_strings = [
|
|
elem.text if elem.text else "" for elem in root.findall(".//ss:t", ns_ss)
|
|
]
|
|
|
|
# Read ACO REACH Provider sheet (sheet3)
|
|
sheet3_xml = zip_ref.read("xl/worksheets/sheet3.xml")
|
|
sheet3_root = ET.fromstring(sheet3_xml)
|
|
rows = sheet3_root.findall(
|
|
".//{http://schemas.openxmlformats.org/spreadsheetml/2006/main}row"
|
|
)
|
|
|
|
# Extract first row (header)
|
|
header_row = rows[0]
|
|
cells = header_row.findall(
|
|
"{http://schemas.openxmlformats.org/spreadsheetml/2006/main}c"
|
|
)
|
|
|
|
headers = []
|
|
for cell in cells:
|
|
cell_type = cell.get("t")
|
|
value_elem = cell.find(
|
|
"{http://schemas.openxmlformats.org/spreadsheetml/2006/main}v"
|
|
)
|
|
|
|
if value_elem is not None:
|
|
if cell_type == "s":
|
|
idx = int(value_elem.text)
|
|
if idx < len(shared_strings):
|
|
headers.append(shared_strings[idx])
|
|
else:
|
|
headers.append(value_elem.text)
|
|
else:
|
|
headers.append(None)
|
|
|
|
return headers
|
|
|
|
|
|
def normalize_field_name(header: str) -> str:
|
|
"""Convert header to Python snake_case field name."""
|
|
import re
|
|
|
|
# Remove newlines and extra spaces
|
|
header = re.sub(r"\s+", " ", header.strip())
|
|
|
|
# Convert to snake case
|
|
# Replace spaces and special chars with underscore
|
|
header = re.sub(r"[^\w\s]", "", header)
|
|
header = re.sub(r"\s+", "_", header)
|
|
header = header.lower()
|
|
|
|
# Remove leading/trailing underscores
|
|
header = header.strip("_")
|
|
|
|
return header
|
|
|
|
|
|
def infer_field_type(header: str) -> str:
|
|
"""Infer field type from header name."""
|
|
header_lower = header.lower()
|
|
|
|
# Boolean attestations
|
|
if "attest" in header_lower:
|
|
return "bool | None"
|
|
|
|
# Email
|
|
if "email" in header_lower:
|
|
return "str | None"
|
|
|
|
# Identifiers (required)
|
|
if any(k in header_lower for k in ["npi", "tin", "ccn"]):
|
|
return "str | None"
|
|
|
|
# Names and addresses
|
|
if any(
|
|
k in header_lower for k in ["name", "address", "city", "state", "county", "zip"]
|
|
):
|
|
return "str | None"
|
|
|
|
# Most other fields are optional strings
|
|
return "str | None"
|
|
|
|
|
|
def generate_table_model(headers: list[str], output_path: Path) -> None:
|
|
"""Generate SQLTable model for REACH participants."""
|
|
lines = []
|
|
|
|
lines.append('"""ACO REACH Participant Provider table model.')
|
|
lines.append("")
|
|
lines.append("Generated from: dev/ACO_REACH_bulk_upload_participants 5-19 (1).xlsx")
|
|
lines.append("")
|
|
lines.append(
|
|
"This table represents the participant provider records that ACOs submit"
|
|
)
|
|
lines.append("via bulk upload to add providers to their participant list.")
|
|
lines.append('"""')
|
|
lines.append("")
|
|
lines.append("from __future__ import annotations")
|
|
lines.append("")
|
|
lines.append("from typing import ClassVar")
|
|
lines.append("")
|
|
lines.append("from aco.table.base import SQLTable")
|
|
lines.append("")
|
|
lines.append("")
|
|
lines.append("class ReachParticipants(SQLTable):")
|
|
lines.append(' """ACO REACH Participant Provider bulk upload records.')
|
|
lines.append("")
|
|
lines.append(" File: DCE_bulk_upload_participants_DXXXX.xlsx")
|
|
lines.append(" Sheet: ACO REACH Provider")
|
|
lines.append(" ")
|
|
lines.append(
|
|
" Participant providers include individual practitioners, organizations,"
|
|
)
|
|
lines.append(" and institutional facilities that have agreements with the ACO.")
|
|
lines.append(' """')
|
|
lines.append("")
|
|
|
|
# Add schema/tablename with ClassVar annotation
|
|
lines.append(' schema__: ClassVar[str] = "reach"')
|
|
lines.append(' tablename: ClassVar[str] = "participants"')
|
|
lines.append("")
|
|
|
|
# Process each header
|
|
for header in headers:
|
|
if not header or not header.strip():
|
|
continue
|
|
|
|
# Skip the instructions column
|
|
if "This excel document" in header:
|
|
continue
|
|
|
|
field_name = normalize_field_name(header)
|
|
if not field_name or field_name in ("", "_"):
|
|
continue
|
|
|
|
field_type = infer_field_type(header)
|
|
|
|
# Add field
|
|
lines.append(f" {field_name}: {field_type}")
|
|
lines.append(f' """{header}"""')
|
|
lines.append("")
|
|
|
|
output_path.write_text("\n".join(lines))
|
|
print(f"✓ Generated {output_path}")
|
|
|
|
|
|
def generate_rex_parser(headers: list[str], output_path: Path) -> None:
|
|
"""Generate rex parser for REACH participants."""
|
|
lines = []
|
|
|
|
lines.append('"""Rex parser for ACO REACH Participant Provider bulk upload files.')
|
|
lines.append("")
|
|
lines.append("Parses Excel bulk upload templates submitted by ACOs to add/update")
|
|
lines.append("participant providers.")
|
|
lines.append("")
|
|
lines.append("File Pattern: DCE_bulk_upload_participants_DXXXX.xlsx")
|
|
lines.append("Sheet: ACO REACH Provider")
|
|
lines.append('"""')
|
|
lines.append("")
|
|
lines.append("from __future__ import annotations")
|
|
lines.append("")
|
|
lines.append("import zipfile")
|
|
lines.append("from typing import Iterator")
|
|
lines.append("from xml.etree import ElementTree as ET")
|
|
lines.append("")
|
|
lines.append("from aco.table.reach_participants import ReachParticipants")
|
|
lines.append("")
|
|
lines.append("")
|
|
lines.append(
|
|
"def parse_reach_participants(file_path: str) -> Iterator[ReachParticipants]:"
|
|
)
|
|
lines.append(' """Parse REACH participant provider bulk upload Excel file.')
|
|
lines.append("")
|
|
lines.append(
|
|
" Handles the corrupt stylesheet issue by reading XML directly from ZIP."
|
|
)
|
|
lines.append(' """')
|
|
lines.append(' with zipfile.ZipFile(file_path, "r") as zip_ref:')
|
|
lines.append(" # Read shared strings")
|
|
lines.append(' strings_xml = zip_ref.read("xl/sharedStrings.xml")')
|
|
lines.append(" root = ET.fromstring(strings_xml)")
|
|
lines.append(
|
|
' ns_ss = {"ss": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}'
|
|
)
|
|
lines.append(" shared_strings = [")
|
|
lines.append(
|
|
' elem.text if elem.text else "" for elem in root.findall(".//ss:t", ns_ss)'
|
|
)
|
|
lines.append(" ]")
|
|
lines.append("")
|
|
lines.append(" # Read ACO REACH Provider sheet (sheet3)")
|
|
lines.append(' sheet3_xml = zip_ref.read("xl/worksheets/sheet3.xml")')
|
|
lines.append(" sheet3_root = ET.fromstring(sheet3_xml)")
|
|
lines.append(" rows = sheet3_root.findall(")
|
|
lines.append(
|
|
' ".//{http://schemas.openxmlformats.org/spreadsheetml/2006/main}row"'
|
|
)
|
|
lines.append(" )")
|
|
lines.append("")
|
|
lines.append(" # Extract header from first row")
|
|
lines.append(" header_row = rows[0]")
|
|
lines.append(" header_cells = header_row.findall(")
|
|
lines.append(
|
|
' "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}c"'
|
|
)
|
|
lines.append(" )")
|
|
lines.append("")
|
|
lines.append(" headers = []")
|
|
lines.append(" for cell in header_cells:")
|
|
lines.append(' cell_type = cell.get("t")')
|
|
lines.append(" value_elem = cell.find(")
|
|
lines.append(
|
|
' "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}v"'
|
|
)
|
|
lines.append(" )")
|
|
lines.append(' if value_elem is not None and cell_type == "s":')
|
|
lines.append(" idx = int(value_elem.text)")
|
|
lines.append(
|
|
" headers.append(shared_strings[idx] if idx < len(shared_strings) else None)"
|
|
)
|
|
lines.append(" else:")
|
|
lines.append(" headers.append(None)")
|
|
lines.append("")
|
|
lines.append(" # Map headers to field names")
|
|
lines.append(" field_mapping = {}")
|
|
lines.append(" for i, header in enumerate(headers):")
|
|
lines.append(
|
|
' if header and header.strip() and "This excel document" not in header:'
|
|
)
|
|
lines.append(" field_name = normalize_field_name(header)")
|
|
lines.append(" if field_name:")
|
|
lines.append(" field_mapping[i] = field_name")
|
|
lines.append("")
|
|
lines.append(" # Process data rows (starting from row 2)")
|
|
lines.append(" for row in rows[1:]:")
|
|
lines.append(" cells = row.findall(")
|
|
lines.append(
|
|
' "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}c"'
|
|
)
|
|
lines.append(" )")
|
|
lines.append("")
|
|
lines.append(" # Extract cell values")
|
|
lines.append(" row_data = {}")
|
|
lines.append(" for i, cell in enumerate(cells):")
|
|
lines.append(" if i not in field_mapping:")
|
|
lines.append(" continue")
|
|
lines.append("")
|
|
lines.append(' cell_type = cell.get("t")')
|
|
lines.append(" value_elem = cell.find(")
|
|
lines.append(
|
|
' "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}v"'
|
|
)
|
|
lines.append(" )")
|
|
lines.append("")
|
|
lines.append(" if value_elem is not None:")
|
|
lines.append(' if cell_type == "s":')
|
|
lines.append(" idx = int(value_elem.text)")
|
|
lines.append(
|
|
" value = shared_strings[idx] if idx < len(shared_strings) else None"
|
|
)
|
|
lines.append(" else:")
|
|
lines.append(" value = value_elem.text")
|
|
lines.append(" row_data[field_mapping[i]] = value")
|
|
lines.append(" else:")
|
|
lines.append(" row_data[field_mapping[i]] = None")
|
|
lines.append("")
|
|
lines.append(" # Skip empty rows")
|
|
lines.append(
|
|
" if not any(v for v in row_data.values() if v and str(v).strip()):"
|
|
)
|
|
lines.append(" continue")
|
|
lines.append("")
|
|
lines.append(" # Convert boolean fields")
|
|
lines.append(" for field_name, value in row_data.items():")
|
|
lines.append(' if field_name.startswith("i_attest"):')
|
|
lines.append(
|
|
' if value and str(value).strip().upper() in ("Y", "YES", "TRUE", "1"):'
|
|
)
|
|
lines.append(" row_data[field_name] = True")
|
|
lines.append(
|
|
' elif value and str(value).strip().upper() in ("N", "NO", "FALSE", "0"):'
|
|
)
|
|
lines.append(" row_data[field_name] = False")
|
|
lines.append(" else:")
|
|
lines.append(" row_data[field_name] = None")
|
|
lines.append("")
|
|
lines.append(" yield ReachParticipants(**row_data)")
|
|
lines.append("")
|
|
lines.append("")
|
|
lines.append("def normalize_field_name(header: str) -> str:")
|
|
lines.append(' """Convert header to Python snake_case field name."""')
|
|
lines.append(" import re")
|
|
lines.append(' header = re.sub(r"\\s+", " ", header.strip())')
|
|
lines.append(' header = re.sub(r"[^\\w\\s]", "", header)')
|
|
lines.append(' header = re.sub(r"\\s+", "_", header)')
|
|
lines.append(' return header.lower().strip("_")')
|
|
lines.append("")
|
|
lines.append("")
|
|
lines.append(
|
|
"def load_reach_participants(file_path: str) -> list[ReachParticipants]:"
|
|
)
|
|
lines.append(' """Load all participant records from bulk upload file."""')
|
|
lines.append(" return list(parse_reach_participants(file_path))")
|
|
lines.append("")
|
|
|
|
output_path.write_text("\n".join(lines))
|
|
print(f"✓ Generated {output_path}")
|
|
|
|
|
|
def main():
|
|
"""Generate REACH participants table model and rex parser."""
|
|
project_root = Path(__file__).resolve().parents[2]
|
|
xlsx_path = (
|
|
project_root
|
|
/ "dev"
|
|
/ "seeds"
|
|
/ "ACO_REACH_bulk_upload_participants 5-19 (1).xlsx"
|
|
)
|
|
|
|
print("Generating ACO REACH Participants integration...")
|
|
print("=" * 70)
|
|
|
|
# Extract headers
|
|
print(f"\n✓ Extracting headers from {xlsx_path.name}")
|
|
headers = extract_headers_from_template(str(xlsx_path))
|
|
print(f" Found {len(headers)} columns")
|
|
|
|
# Generate table model
|
|
table_path = project_root / "src" / "aco" / "table" / "reach_participants.py"
|
|
generate_table_model(headers, table_path)
|
|
|
|
# Generate rex parser
|
|
rex_path = project_root / "src" / "aco" / "rex" / "reach_participants.py"
|
|
generate_rex_parser(headers, rex_path)
|
|
|
|
print("\n" + "=" * 70)
|
|
print("✓ REACH Participants integration complete")
|
|
print("=" * 70)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|