Skip to content

Commit b36b72e

Browse files
authored
Merge branch 'main' into bob/refactor-csv-bulk-upload-pt-1
2 parents 3b39ce2 + 24fa541 commit b36b72e

3 files changed

Lines changed: 521 additions & 35 deletions

File tree

refiner/app/api/v1/configurations/exports.py

Lines changed: 130 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
from datetime import UTC, datetime
33
from io import StringIO
44
from logging import Logger
5+
from typing import Literal
56
from uuid import UUID
67

78
from fastapi import APIRouter, Depends, HTTPException, Response, status
9+
from fastapi.responses import StreamingResponse
810

911
from app.api.auth.middleware import get_logged_in_user
1012
from app.db.conditions.db import (
@@ -13,10 +15,17 @@
1315
)
1416
from app.db.conditions.model import DbCondition
1517
from app.db.configurations.db import get_configuration_by_id_db
16-
from app.db.configurations.model import DbConfiguration
18+
from app.db.configurations.model import (
19+
DbConfiguration,
20+
DbConfigurationSectionProcessing,
21+
DbNarrativeAction,
22+
DbSectionAction,
23+
)
1724
from app.db.pool import AsyncDatabaseConnection, get_db
1825
from app.db.users.model import DbUser
1926
from app.services.code_systems import get_all_code_systems_by_key
27+
from app.services.ecr.policy import NARRATIVE_ONLY_SECTIONS
28+
from app.services.file_io import ZipFileItem, ZipFilePackage
2029
from app.services.logger import get_logger
2130

2231
router = APIRouter(prefix="/{configuration_id}/export")
@@ -52,25 +61,122 @@ async def get_configuration_export(
5261
included_conditions=config.included_conditions, db=db
5362
)
5463

55-
csv_bytes = await _build_config_csv(
64+
codes_csv_content = await _build_config_csv(
5665
config=config, conditions=included_conditions, logger=logger, db=db
5766
)
67+
sections_csv_content = _build_sections_csv(sections=config.section_processing)
5868

59-
filename = _build_export_filename(config_name=config.name)
69+
timestamp = _get_timestamp()
6070

61-
return Response(
62-
content=csv_bytes,
63-
media_type="text/csv; charset=utf-8",
64-
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
71+
zip_package = ZipFilePackage(
72+
name=_build_export_filename(
73+
filename="Configuration_Export",
74+
extension="zip",
75+
config_name=config.name,
76+
config_version=config.version,
77+
timestamp=timestamp,
78+
)
79+
)
80+
81+
zip_package.add(
82+
ZipFileItem(
83+
file_name=_build_export_filename(
84+
filename="Code_Export",
85+
extension="csv",
86+
config_name=config.name,
87+
config_version=config.version,
88+
timestamp=timestamp,
89+
),
90+
file_content=codes_csv_content,
91+
)
6592
)
6693

94+
zip_package.add(
95+
ZipFileItem(
96+
file_name=_build_export_filename(
97+
filename="Section_Export",
98+
extension="csv",
99+
config_name=config.name,
100+
config_version=config.version,
101+
timestamp=timestamp,
102+
),
103+
file_content=sections_csv_content,
104+
)
105+
)
106+
107+
return StreamingResponse(
108+
content=zip_package.iter_chunks(),
109+
media_type="application/zip",
110+
headers={
111+
"Content-Disposition": f'attachment; filename="{zip_package.get_name()}"'
112+
},
113+
)
114+
115+
116+
def _build_sections_csv(
117+
sections: list[DbConfigurationSectionProcessing],
118+
) -> str:
119+
"""Build a CSV summarizing configuration section information."""
120+
with StringIO() as csv_text:
121+
writer = csv.writer(csv_text)
122+
writer.writerow(
123+
["Section Name", "LOINC", "Include", "Coded Data", "Narrative Data"]
124+
)
125+
126+
for section in sorted(sections, key=lambda r: r.name.lower()):
127+
writer.writerow(
128+
[
129+
section.name,
130+
section.code,
131+
"Yes" if section.include else "No",
132+
_get_coded_data_value(
133+
loinc=section.code,
134+
action=section.action,
135+
included=section.include,
136+
),
137+
_get_narrative_data_value(
138+
narrative=section.narrative, included=section.include
139+
),
140+
]
141+
)
142+
return csv_text.getvalue()
143+
144+
145+
def _get_coded_data_value(loinc: str, action: DbSectionAction, included: bool) -> str:
146+
if not included:
147+
return "N/A"
148+
149+
if loinc in NARRATIVE_ONLY_SECTIONS:
150+
return "N/A"
151+
152+
if action == "retain":
153+
return "Keep original"
154+
155+
if action == "refine":
156+
return "Refine"
157+
158+
return "N/A"
159+
160+
161+
def _get_narrative_data_value(narrative: DbNarrativeAction, included: bool) -> str:
162+
if not included:
163+
return "N/A"
164+
165+
if narrative == "retain":
166+
return "Keep original"
167+
168+
if narrative == "remove":
169+
return "Exclude"
170+
171+
return "Reconstruct"
172+
67173

68174
async def _build_config_csv(
69175
config: DbConfiguration,
70176
conditions: list[DbCondition],
71177
logger: Logger,
72178
db: AsyncDatabaseConnection,
73-
) -> bytes:
179+
) -> str:
74180
"""Build the CSV export content for a configuration."""
75181

76182
code_systems = await get_all_code_systems_by_key(db=db)
@@ -113,11 +219,22 @@ async def _build_config_csv(
113219
]
114220
)
115221

116-
return csv_text.getvalue().encode("utf-8")
222+
return csv_text.getvalue()
223+
224+
225+
def _get_timestamp() -> str:
226+
now = datetime.now(UTC)
227+
timestamp = now.strftime("%m%d%y_%H_%M_%S")
228+
return timestamp
117229

118230

119-
def _build_export_filename(config_name: str) -> str:
231+
def _build_export_filename(
232+
filename: str,
233+
extension: Literal["csv", "zip"],
234+
config_name: str,
235+
config_version: int,
236+
timestamp: str,
237+
) -> str:
120238
"""Build a timestamped filename for a configuration export."""
121-
safe_name = config_name.replace(" ", "_")
122-
timestamp = datetime.now(UTC).strftime("%m%d%y_%H_%M_%S")
123-
return f"{safe_name}_Code_Export_{timestamp}.csv"
239+
condition_grouper = config_name.replace(" ", "_")
240+
return f"{condition_grouper}_v{config_version}_{filename}_{timestamp}.{extension}"

refiner/app/services/file_io.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import io
22
import time
3+
from collections.abc import Iterator
34
from dataclasses import dataclass
45
from io import BytesIO
5-
from zipfile import BadZipFile, ZipFile, ZipInfo
6+
from zipfile import ZIP_DEFLATED, BadZipFile, ZipFile, ZipInfo
67

78
from chardet import detect
89
from lxml import etree
@@ -69,6 +70,16 @@ def get_items(self) -> list[ZipFileItem]:
6970
"""
7071
return list(self._packaged_items)
7172

73+
def iter_chunks(self) -> Iterator[bytes]:
74+
"""
75+
Yields the zip file contents as chunks. Useful for usage with a StreamingResponse.
76+
"""
77+
buffer = BytesIO()
78+
with ZipFile(buffer, "w", ZIP_DEFLATED) as zf:
79+
for item in self._packaged_items:
80+
zf.writestr(item.file_name, item.file_content)
81+
yield buffer.getvalue()
82+
7283

7384
def parse_xml(xml_content: str | bytes) -> _Element:
7485
"""

0 commit comments

Comments
 (0)