Skip to content

Commit f7651ea

Browse files
authored
feat: implement effect for exporting ccdas (#1477)
1 parent 44a20c7 commit f7651ea

File tree

8 files changed

+289
-2
lines changed

8 files changed

+289
-2
lines changed

canvas_generated/messages/effects_pb2.py

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

canvas_generated/messages/effects_pb2.pyi

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,7 @@ class EffectType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
335335
CREATE_PATIENT_FACILITY_ADDRESS: _ClassVar[EffectType]
336336
UPDATE_PATIENT_FACILITY_ADDRESS: _ClassVar[EffectType]
337337
DELETE_PATIENT_FACILITY_ADDRESS: _ClassVar[EffectType]
338+
CREATE_CCDA: _ClassVar[EffectType]
338339
HOMEPAGE_CONFIGURATION: _ClassVar[EffectType]
339340
UNKNOWN_EFFECT: EffectType
340341
LOG: EffectType
@@ -664,6 +665,7 @@ UPDATE_EXTERNAL_EVENT: EffectType
664665
CREATE_PATIENT_FACILITY_ADDRESS: EffectType
665666
UPDATE_PATIENT_FACILITY_ADDRESS: EffectType
666667
DELETE_PATIENT_FACILITY_ADDRESS: EffectType
668+
CREATE_CCDA: EffectType
667669
HOMEPAGE_CONFIGURATION: EffectType
668670

669671
class Effect(_message.Message):
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .ccda_export import CreateCCDA, DocumentType
2+
3+
__all__ = __exports__ = ("CreateCCDA", "DocumentType")
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import xml.etree.ElementTree as ET
2+
from enum import StrEnum
3+
from typing import Any
4+
5+
from pydantic import Field
6+
from pydantic_core import InitErrorDetails
7+
8+
from canvas_generated.messages.effects_pb2 import EffectType
9+
from canvas_sdk.effects.base import _BaseEffect
10+
from canvas_sdk.v1.data import Patient
11+
from logger import log
12+
13+
14+
class DocumentType(StrEnum):
15+
"""Valid CCDA document types."""
16+
17+
CCD = "CCD"
18+
REFERRAL = "Referral"
19+
20+
21+
class CreateCCDA(_BaseEffect):
22+
"""
23+
An Effect that will create a CCDA document for a patient with the provided XML content.
24+
25+
Attributes:
26+
patient_id: The patient's key (required)
27+
content: The CCDA XML content as a string (required)
28+
document_type: Type of CCDA document (default: DocumentType.CCD)
29+
"""
30+
31+
class Meta:
32+
effect_type = EffectType.CREATE_CCDA
33+
34+
patient_id: str = Field(min_length=1)
35+
content: str = Field(min_length=1)
36+
document_type: DocumentType = Field(default=DocumentType.CCD, strict=False)
37+
38+
@property
39+
def values(self) -> dict[str, Any]:
40+
"""The CreateCCDA's values."""
41+
return {
42+
"patient_id": self.patient_id,
43+
"content": self.content,
44+
"document_type": self.document_type.value,
45+
}
46+
47+
def _get_error_details(self, method: Any) -> list[InitErrorDetails]:
48+
errors = super()._get_error_details(method)
49+
50+
log.info(f"Creating CCDA document for patient {self.patient_id}")
51+
if not Patient.objects.filter(id=self.patient_id).exists():
52+
errors.append(
53+
self._create_error_detail(
54+
"value",
55+
f"Patient with ID {self.patient_id} does not exist.",
56+
self.patient_id,
57+
)
58+
)
59+
60+
# Validate XML content
61+
try:
62+
ET.fromstring(self.content)
63+
except ET.ParseError as e:
64+
errors.append(
65+
self._create_error_detail(
66+
"value",
67+
f"Invalid XML content: {e}",
68+
self.content[:100] if len(self.content) > 100 else self.content,
69+
)
70+
)
71+
72+
return errors
73+
74+
75+
__exports__ = ("CreateCCDA", "DocumentType")

canvas_sdk/tests/effects/ccda/__init__.py

Whitespace-only changes.
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import json
2+
from collections.abc import Generator
3+
from unittest.mock import MagicMock, patch
4+
5+
import pytest
6+
from pydantic import ValidationError
7+
8+
from canvas_generated.messages.effects_pb2 import EffectType
9+
from canvas_sdk.effects.ccda import CreateCCDA, DocumentType
10+
11+
SAMPLE_XML_CONTENT = """<?xml version="1.0" encoding="UTF-8"?>
12+
<ClinicalDocument xmlns="urn:hl7-org:v3">
13+
<typeId root="2.16.840.1.113883.1.3" extension="POCD_HD000040"/>
14+
</ClinicalDocument>"""
15+
16+
INVALID_XML_CONTENT = """<ClinicalDocument>
17+
<unclosed_tag>
18+
</ClinicalDocument>"""
19+
20+
21+
@pytest.fixture
22+
def mock_patient_exists() -> Generator[MagicMock]:
23+
"""Mock Patient.objects to return that the patient exists."""
24+
with patch("canvas_sdk.effects.ccda.ccda_export.Patient.objects") as mock_patient:
25+
mock_patient.filter.return_value.exists.return_value = True
26+
yield mock_patient
27+
28+
29+
def test_values_with_all_fields() -> None:
30+
"""Test that values property returns all fields correctly."""
31+
effect = CreateCCDA(
32+
patient_id="patient-key-123",
33+
content=SAMPLE_XML_CONTENT,
34+
document_type=DocumentType.CCD,
35+
)
36+
37+
assert effect.values == {
38+
"patient_id": "patient-key-123",
39+
"content": SAMPLE_XML_CONTENT,
40+
"document_type": "CCD",
41+
}
42+
43+
44+
def test_values_with_minimal_fields() -> None:
45+
"""Test that values property returns correctly with required fields and default document_type."""
46+
effect = CreateCCDA(
47+
patient_id="patient-key-123",
48+
content=SAMPLE_XML_CONTENT,
49+
)
50+
51+
assert effect.values == {
52+
"patient_id": "patient-key-123",
53+
"content": SAMPLE_XML_CONTENT,
54+
"document_type": "CCD",
55+
}
56+
57+
58+
def test_values_with_referral_document_type() -> None:
59+
"""Test that values property works with Referral document type."""
60+
effect = CreateCCDA(
61+
patient_id="patient-key-123",
62+
content=SAMPLE_XML_CONTENT,
63+
document_type=DocumentType.REFERRAL,
64+
)
65+
66+
assert effect.values["document_type"] == "Referral"
67+
68+
69+
def test_document_type_accepts_string_value() -> None:
70+
"""Test that document_type accepts plain strings via strict=False."""
71+
effect = CreateCCDA(
72+
patient_id="patient-key-123",
73+
content=SAMPLE_XML_CONTENT,
74+
document_type="Referral", # type: ignore[arg-type]
75+
)
76+
77+
assert effect.document_type == DocumentType.REFERRAL
78+
assert effect.values["document_type"] == "Referral"
79+
80+
81+
def test_effect_payload_structure() -> None:
82+
"""Test that effect_payload has correct structure."""
83+
effect = CreateCCDA(
84+
patient_id="patient-key-123",
85+
content=SAMPLE_XML_CONTENT,
86+
document_type=DocumentType.CCD,
87+
)
88+
89+
payload = effect.effect_payload
90+
assert "data" in payload
91+
assert payload["data"]["patient_id"] == "patient-key-123"
92+
assert payload["data"]["content"] == SAMPLE_XML_CONTENT
93+
assert payload["data"]["document_type"] == "CCD"
94+
95+
96+
def test_construction_raises_error_without_patient_id() -> None:
97+
"""Test that construction raises validation error when patient_id is missing."""
98+
with pytest.raises(ValidationError) as exc_info:
99+
CreateCCDA(content=SAMPLE_XML_CONTENT) # type: ignore[call-arg]
100+
101+
assert "patient_id" in str(exc_info.value)
102+
103+
104+
def test_construction_raises_error_with_empty_patient_id() -> None:
105+
"""Test that construction raises validation error when patient_id is empty."""
106+
with pytest.raises(ValidationError) as exc_info:
107+
CreateCCDA(patient_id="", content=SAMPLE_XML_CONTENT)
108+
109+
assert "patient_id" in str(exc_info.value)
110+
111+
112+
def test_construction_raises_error_without_content() -> None:
113+
"""Test that construction raises validation error when content is missing."""
114+
with pytest.raises(ValidationError) as exc_info:
115+
CreateCCDA(patient_id="patient-key-123") # type: ignore[call-arg]
116+
117+
assert "content" in str(exc_info.value)
118+
119+
120+
def test_construction_raises_error_with_empty_content() -> None:
121+
"""Test that construction raises validation error when content is empty."""
122+
with pytest.raises(ValidationError) as exc_info:
123+
CreateCCDA(patient_id="patient-key-123", content="")
124+
125+
assert "content" in str(exc_info.value)
126+
127+
128+
def test_default_document_type_is_ccd() -> None:
129+
"""Test that document_type defaults to CCD."""
130+
effect = CreateCCDA(patient_id="patient-key-123", content=SAMPLE_XML_CONTENT)
131+
assert effect.document_type == DocumentType.CCD
132+
133+
134+
def test_effect_payload_serialization() -> None:
135+
"""Test that effect_payload contains correctly serialized data."""
136+
effect = CreateCCDA(
137+
patient_id="test-patient-key",
138+
content=SAMPLE_XML_CONTENT,
139+
document_type=DocumentType.REFERRAL,
140+
)
141+
142+
payload = effect.effect_payload
143+
assert payload["data"]["patient_id"] == "test-patient-key"
144+
assert payload["data"]["content"] == SAMPLE_XML_CONTENT
145+
assert payload["data"]["document_type"] == "Referral"
146+
147+
148+
def test_document_type_enum_values() -> None:
149+
"""Test DocumentType enum has expected values."""
150+
assert DocumentType.CCD.value == "CCD"
151+
assert DocumentType.REFERRAL.value == "Referral"
152+
153+
154+
def test_apply_succeeds_with_valid_data(mock_patient_exists: MagicMock) -> None:
155+
"""Test that apply returns an Effect with correct type and payload."""
156+
effect = CreateCCDA(
157+
patient_id="patient-key-123",
158+
content=SAMPLE_XML_CONTENT,
159+
document_type=DocumentType.CCD,
160+
)
161+
162+
applied = effect.apply()
163+
164+
assert applied.type == EffectType.CREATE_CCDA
165+
payload = json.loads(applied.payload)
166+
assert payload["data"]["patient_id"] == "patient-key-123"
167+
assert payload["data"]["content"] == SAMPLE_XML_CONTENT
168+
assert payload["data"]["document_type"] == "CCD"
169+
170+
171+
def test_apply_raises_error_for_nonexistent_patient() -> None:
172+
"""Test that apply raises validation error when patient does not exist."""
173+
with patch("canvas_sdk.effects.ccda.ccda_export.Patient.objects") as mock_patient:
174+
mock_patient.filter.return_value.exists.return_value = False
175+
176+
effect = CreateCCDA(
177+
patient_id="nonexistent-patient",
178+
content=SAMPLE_XML_CONTENT,
179+
)
180+
181+
with pytest.raises(ValidationError) as exc_info:
182+
effect.apply()
183+
184+
assert "does not exist" in str(exc_info.value)
185+
186+
187+
def test_apply_raises_error_for_invalid_xml(mock_patient_exists: MagicMock) -> None:
188+
"""Test that apply raises validation error when content is invalid XML."""
189+
effect = CreateCCDA(
190+
patient_id="patient-key-123",
191+
content=INVALID_XML_CONTENT,
192+
)
193+
194+
with pytest.raises(ValidationError) as exc_info:
195+
effect.apply()
196+
197+
assert "Invalid XML content" in str(exc_info.value)

plugin_runner/allowed-module-imports.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,14 @@
455455
"Event",
456456
"EventRecurrence"
457457
],
458+
"canvas_sdk.effects.ccda": [
459+
"CreateCCDA",
460+
"DocumentType"
461+
],
462+
"canvas_sdk.effects.ccda.ccda_export": [
463+
"CreateCCDA",
464+
"DocumentType"
465+
],
458466
"canvas_sdk.effects.claim": [
459467
"BannerAlertIntent",
460468
"ClaimBillingProvider",

protobufs/canvas_generated/messages/effects.proto

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,8 @@ enum EffectType {
422422
UPDATE_PATIENT_FACILITY_ADDRESS = 8201;
423423
DELETE_PATIENT_FACILITY_ADDRESS = 8202;
424424

425+
CREATE_CCDA = 8300;
426+
425427
HOMEPAGE_CONFIGURATION = 9000;
426428
}
427429

0 commit comments

Comments
 (0)