canvas 0.1.15__py3-none-any.whl → 0.2.10__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of canvas might be problematic. Click here for more details.
- {canvas-0.1.15.dist-info → canvas-0.2.10.dist-info}/METADATA +7 -1
- canvas-0.2.10.dist-info/RECORD +143 -0
- canvas_cli/apps/plugin/plugin.py +51 -9
- canvas_cli/apps/plugin/tests.py +51 -0
- canvas_cli/tests.py +193 -4
- canvas_cli/utils/validators/manifest_schema.py +1 -0
- canvas_generated/messages/effects_pb2.py +2 -2
- canvas_generated/messages/effects_pb2.pyi +138 -0
- canvas_generated/messages/events_pb2.py +3 -3
- canvas_generated/messages/events_pb2.pyi +616 -0
- canvas_sdk/__init__.py +7 -0
- canvas_sdk/base.py +6 -2
- canvas_sdk/commands/__init__.py +26 -0
- canvas_sdk/commands/base.py +35 -32
- canvas_sdk/commands/commands/allergy.py +49 -0
- canvas_sdk/commands/commands/assess.py +1 -1
- canvas_sdk/commands/commands/close_goal.py +22 -0
- canvas_sdk/commands/commands/diagnose.py +3 -3
- canvas_sdk/commands/commands/family_history.py +18 -0
- canvas_sdk/commands/commands/goal.py +3 -3
- canvas_sdk/commands/commands/history_present_illness.py +1 -1
- canvas_sdk/commands/commands/instruct.py +17 -0
- canvas_sdk/commands/commands/lab_order.py +33 -0
- canvas_sdk/commands/commands/medical_history.py +34 -0
- canvas_sdk/commands/commands/medication_statement.py +1 -1
- canvas_sdk/commands/commands/past_surgical_history.py +28 -0
- canvas_sdk/commands/commands/perform.py +17 -0
- canvas_sdk/commands/commands/plan.py +2 -2
- canvas_sdk/commands/commands/prescribe.py +10 -7
- canvas_sdk/commands/commands/questionnaire.py +1 -1
- canvas_sdk/commands/commands/refill.py +16 -0
- canvas_sdk/commands/commands/remove_allergy.py +26 -0
- canvas_sdk/commands/commands/stop_medication.py +1 -1
- canvas_sdk/commands/commands/task.py +52 -0
- canvas_sdk/commands/commands/update_diagnosis.py +27 -0
- canvas_sdk/commands/commands/update_goal.py +1 -1
- canvas_sdk/commands/commands/vitals.py +78 -0
- canvas_sdk/commands/constants.py +7 -0
- canvas_sdk/commands/tests/protocol/__init__.py +0 -0
- canvas_sdk/commands/tests/protocol/tests.py +55 -0
- canvas_sdk/commands/tests/schema/__init__.py +0 -0
- canvas_sdk/commands/tests/schema/tests.py +104 -0
- canvas_sdk/commands/tests/test_utils.py +170 -6
- canvas_sdk/commands/tests/unit/__init__.py +0 -0
- canvas_sdk/commands/tests/{tests.py → unit/tests.py} +20 -194
- canvas_sdk/data/client.py +82 -0
- canvas_sdk/data/patient.py +1 -21
- canvas_sdk/effects/banner_alert/add_banner_alert.py +8 -7
- canvas_sdk/effects/banner_alert/remove_banner_alert.py +3 -2
- canvas_sdk/effects/banner_alert/tests.py +224 -0
- canvas_sdk/effects/base.py +3 -5
- canvas_sdk/effects/patient_chart_summary_configuration.py +39 -0
- canvas_sdk/effects/protocol_card/__init__.py +1 -0
- canvas_sdk/effects/protocol_card/protocol_card.py +83 -0
- canvas_sdk/effects/protocol_card/tests.py +184 -0
- canvas_sdk/handlers/base.py +14 -1
- canvas_sdk/protocols/base.py +14 -0
- canvas_sdk/protocols/clinical_quality_measure.py +41 -0
- canvas_sdk/utils/db.py +17 -0
- canvas_sdk/v1/__init__.py +0 -0
- canvas_sdk/v1/data/__init__.py +3 -0
- canvas_sdk/v1/data/allergy_intolerance.py +63 -0
- canvas_sdk/v1/data/base.py +47 -0
- canvas_sdk/v1/data/condition.py +48 -0
- canvas_sdk/v1/data/lab.py +96 -0
- canvas_sdk/v1/data/medication.py +54 -0
- canvas_sdk/v1/data/patient.py +49 -0
- canvas_sdk/v1/data/user.py +10 -0
- canvas_sdk/value_set/tests/test_value_sets.py +65 -0
- canvas_sdk/value_set/v2022/adverse_event.py +33 -0
- canvas_sdk/value_set/v2022/allergy.py +232 -0
- canvas_sdk/value_set/v2022/assessment.py +215 -0
- canvas_sdk/value_set/v2022/communication.py +325 -0
- canvas_sdk/value_set/v2022/condition.py +40654 -0
- canvas_sdk/value_set/v2022/device.py +174 -0
- canvas_sdk/value_set/v2022/diagnostic_study.py +4967 -0
- canvas_sdk/value_set/v2022/encounter.py +2564 -0
- canvas_sdk/value_set/v2022/immunization.py +341 -0
- canvas_sdk/value_set/v2022/individual_characteristic.py +307 -0
- canvas_sdk/value_set/v2022/intervention.py +1356 -0
- canvas_sdk/value_set/v2022/laboratory_test.py +1250 -0
- canvas_sdk/value_set/v2022/medication.py +5130 -0
- canvas_sdk/value_set/v2022/physical_exam.py +201 -0
- canvas_sdk/value_set/v2022/procedure.py +4037 -0
- canvas_sdk/value_set/v2022/symptom.py +176 -0
- canvas_sdk/value_set/value_set.py +91 -0
- canvas-0.1.15.dist-info/RECORD +0 -95
- canvas_generated/data_access_layer/data_access_layer_pb2.py +0 -30
- canvas_generated/data_access_layer/data_access_layer_pb2.pyi +0 -23
- canvas_generated/data_access_layer/data_access_layer_pb2_grpc.py +0 -66
- canvas_sdk/data/data_access_layer_client.py +0 -95
- canvas_sdk/data/exceptions.py +0 -16
- {canvas-0.1.15.dist-info → canvas-0.2.10.dist-info}/WHEEL +0 -0
- {canvas-0.1.15.dist-info → canvas-0.2.10.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
from contextlib import chdir
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Generator
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
import requests
|
|
9
|
+
from pydantic import ValidationError
|
|
10
|
+
from typer.testing import CliRunner
|
|
11
|
+
|
|
12
|
+
import settings
|
|
13
|
+
from canvas_cli.apps.plugin.plugin import _build_package, plugin_url
|
|
14
|
+
from canvas_cli.main import app
|
|
15
|
+
from canvas_sdk.commands.tests.test_utils import MaskedValue
|
|
16
|
+
from canvas_sdk.effects.banner_alert import AddBannerAlert, RemoveBannerAlert
|
|
17
|
+
|
|
18
|
+
runner = CliRunner()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture(scope="session")
|
|
22
|
+
def token() -> MaskedValue:
|
|
23
|
+
return MaskedValue(
|
|
24
|
+
requests.post(
|
|
25
|
+
f"{settings.INTEGRATION_TEST_URL}/auth/token/",
|
|
26
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
27
|
+
data={
|
|
28
|
+
"grant_type": "client_credentials",
|
|
29
|
+
"client_id": settings.INTEGRATION_TEST_CLIENT_ID,
|
|
30
|
+
"client_secret": settings.INTEGRATION_TEST_CLIENT_SECRET,
|
|
31
|
+
},
|
|
32
|
+
).json()["access_token"]
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.fixture(scope="session")
|
|
37
|
+
def first_patient_id(token: MaskedValue) -> dict:
|
|
38
|
+
headers = {
|
|
39
|
+
"Authorization": f"Bearer {token.value}",
|
|
40
|
+
"Content-Type": "application/json",
|
|
41
|
+
"Accept": "application/json",
|
|
42
|
+
}
|
|
43
|
+
patients = requests.get(f"{settings.INTEGRATION_TEST_URL}/api/Patient", headers=headers).json()
|
|
44
|
+
return patients["entry"][0]["resource"]["key"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.fixture(scope="session")
|
|
48
|
+
def plugin_name() -> str:
|
|
49
|
+
return f"addbanneralert{datetime.now().timestamp()}".replace(".", "")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.fixture(autouse=True, scope="session")
|
|
53
|
+
def write_and_install_protocol_and_clean_up(
|
|
54
|
+
first_patient_id: str, plugin_name: str, token: MaskedValue
|
|
55
|
+
) -> Generator[Any, Any, Any]:
|
|
56
|
+
# write the protocol
|
|
57
|
+
with chdir(Path("./custom-plugins")):
|
|
58
|
+
runner.invoke(app, "init", input=plugin_name)
|
|
59
|
+
|
|
60
|
+
protocol = open(f"./custom-plugins/{plugin_name}/protocols/my_protocol.py", "w")
|
|
61
|
+
protocol.write(
|
|
62
|
+
f"""from canvas_sdk.effects.banner_alert import AddBannerAlert
|
|
63
|
+
from canvas_sdk.events import EventType
|
|
64
|
+
from canvas_sdk.protocols import BaseProtocol
|
|
65
|
+
|
|
66
|
+
class Protocol(BaseProtocol):
|
|
67
|
+
RESPONDS_TO = EventType.Name(EventType.ENCOUNTER_CREATED)
|
|
68
|
+
def compute(self):
|
|
69
|
+
return [
|
|
70
|
+
AddBannerAlert(
|
|
71
|
+
patient_id="{first_patient_id}",
|
|
72
|
+
key="{plugin_name}",
|
|
73
|
+
narrative="this is a test",
|
|
74
|
+
placement=[AddBannerAlert.Placement.CHART],
|
|
75
|
+
intent=AddBannerAlert.Intent.INFO,
|
|
76
|
+
).apply()
|
|
77
|
+
]
|
|
78
|
+
"""
|
|
79
|
+
)
|
|
80
|
+
protocol.close()
|
|
81
|
+
|
|
82
|
+
# install the plugin
|
|
83
|
+
requests.post(
|
|
84
|
+
plugin_url(settings.INTEGRATION_TEST_URL),
|
|
85
|
+
data={"is_enabled": True},
|
|
86
|
+
files={"package": open(_build_package(Path(f"./custom-plugins/{plugin_name}")), "rb")},
|
|
87
|
+
headers={"Authorization": f"Bearer {token.value}"},
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
yield
|
|
91
|
+
|
|
92
|
+
# clean up
|
|
93
|
+
if Path(f"./custom-plugins/{plugin_name}").exists():
|
|
94
|
+
shutil.rmtree(Path(f"./custom-plugins/{plugin_name}"))
|
|
95
|
+
|
|
96
|
+
# disable
|
|
97
|
+
requests.patch(
|
|
98
|
+
plugin_url(settings.INTEGRATION_TEST_URL, plugin_name),
|
|
99
|
+
data={"is_enabled": False},
|
|
100
|
+
headers={
|
|
101
|
+
"Authorization": f"Bearer {token.value}",
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
# delete
|
|
105
|
+
requests.delete(
|
|
106
|
+
plugin_url(settings.INTEGRATION_TEST_URL, plugin_name),
|
|
107
|
+
headers={"Authorization": f"Bearer {token.value}"},
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# confirm no more banner
|
|
111
|
+
patient_banners_none = requests.get(
|
|
112
|
+
f"{settings.INTEGRATION_TEST_URL}/api/BannerAlert/?patient__key={first_patient_id}",
|
|
113
|
+
headers={
|
|
114
|
+
"Authorization": f"Bearer {token.value}",
|
|
115
|
+
},
|
|
116
|
+
).json()
|
|
117
|
+
patient_banner = next(
|
|
118
|
+
(b for b in patient_banners_none["results"] if b["key"] == plugin_name), None
|
|
119
|
+
)
|
|
120
|
+
assert patient_banner is None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@pytest.mark.integtest
|
|
124
|
+
def test_protocol_that_adds_banner_alert(
|
|
125
|
+
token: MaskedValue, plugin_name: str, first_patient_id: str
|
|
126
|
+
) -> None:
|
|
127
|
+
# trigger the event
|
|
128
|
+
requests.post(
|
|
129
|
+
f"{settings.INTEGRATION_TEST_URL}/api/Note/",
|
|
130
|
+
headers={
|
|
131
|
+
"Authorization": f"Bearer {token.value}",
|
|
132
|
+
"Content-Type": "application/json",
|
|
133
|
+
"Accept": "application/json",
|
|
134
|
+
},
|
|
135
|
+
json={
|
|
136
|
+
"patient": 1,
|
|
137
|
+
"provider": 1,
|
|
138
|
+
"note_type": "office",
|
|
139
|
+
"note_type_version": 1,
|
|
140
|
+
"lastModifiedBySessionKey": "8fee3c03a525cebee1d8a6b8e63dd4dg",
|
|
141
|
+
},
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
patient_banners = requests.get(
|
|
145
|
+
f"{settings.INTEGRATION_TEST_URL}/api/BannerAlert/?patient__key={first_patient_id}",
|
|
146
|
+
headers={
|
|
147
|
+
"Authorization": f"Bearer {token.value}",
|
|
148
|
+
},
|
|
149
|
+
).json()
|
|
150
|
+
assert patient_banners["count"] > 0
|
|
151
|
+
|
|
152
|
+
patient_banner = next(b for b in patient_banners["results"] if b["key"] == plugin_name)
|
|
153
|
+
assert patient_banner["pluginName"] == plugin_name
|
|
154
|
+
assert patient_banner["narrative"] == "this is a test"
|
|
155
|
+
assert patient_banner["placement"] == ["chart"]
|
|
156
|
+
assert patient_banner["intent"] == "info"
|
|
157
|
+
assert patient_banner["href"] is None
|
|
158
|
+
assert patient_banner["status"] == "active"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@pytest.mark.parametrize(
|
|
162
|
+
"Effect,params,expected_payload",
|
|
163
|
+
[
|
|
164
|
+
(
|
|
165
|
+
AddBannerAlert,
|
|
166
|
+
{
|
|
167
|
+
"patient_id": "uuid",
|
|
168
|
+
"key": "test-key",
|
|
169
|
+
"narrative": "hellooo",
|
|
170
|
+
"placement": [AddBannerAlert.Placement.APPOINTMENT_CARD],
|
|
171
|
+
"intent": AddBannerAlert.Intent.INFO,
|
|
172
|
+
},
|
|
173
|
+
'{"patient": "uuid", "key": "test-key", "data": {"narrative": "hellooo", "placement": ["appointment_card"], "intent": "info", "href": null}}',
|
|
174
|
+
),
|
|
175
|
+
(
|
|
176
|
+
RemoveBannerAlert,
|
|
177
|
+
{"patient_id": "uuid", "key": "testeroo"},
|
|
178
|
+
'{"patient": "uuid", "key": "testeroo"}',
|
|
179
|
+
),
|
|
180
|
+
],
|
|
181
|
+
)
|
|
182
|
+
def test_banner_alert_apply_method_succeeds_with_all_required_fields(
|
|
183
|
+
Effect: AddBannerAlert | RemoveBannerAlert, params: dict, expected_payload: str
|
|
184
|
+
) -> None:
|
|
185
|
+
b = Effect()
|
|
186
|
+
for k, v in params.items():
|
|
187
|
+
setattr(b, k, v)
|
|
188
|
+
applied = b.apply()
|
|
189
|
+
assert applied.payload == expected_payload
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@pytest.mark.parametrize(
|
|
193
|
+
"Effect,expected_err_msgs",
|
|
194
|
+
[
|
|
195
|
+
(
|
|
196
|
+
AddBannerAlert,
|
|
197
|
+
[
|
|
198
|
+
"5 validation errors for AddBannerAlert",
|
|
199
|
+
"Field 'patient_id' is required to apply an AddBannerAlert [type=missing",
|
|
200
|
+
"Field 'key' is required to apply an AddBannerAlert [type=missing",
|
|
201
|
+
"Field 'narrative' is required to apply an AddBannerAlert [type=missing",
|
|
202
|
+
"Field 'placement' is required to apply an AddBannerAlert [type=missing",
|
|
203
|
+
"Field 'intent' is required to apply an AddBannerAlert [type=missing",
|
|
204
|
+
],
|
|
205
|
+
),
|
|
206
|
+
(
|
|
207
|
+
RemoveBannerAlert,
|
|
208
|
+
[
|
|
209
|
+
"2 validation errors for RemoveBannerAlert",
|
|
210
|
+
"Field 'patient_id' is required to apply a RemoveBannerAlert [type=missing",
|
|
211
|
+
"Field 'key' is required to apply a RemoveBannerAlert [type=missing",
|
|
212
|
+
],
|
|
213
|
+
),
|
|
214
|
+
],
|
|
215
|
+
)
|
|
216
|
+
def test_banner_alert_apply_method_raises_error_without_required_fields(
|
|
217
|
+
Effect: AddBannerAlert | RemoveBannerAlert, expected_err_msgs: str
|
|
218
|
+
) -> None:
|
|
219
|
+
b = Effect()
|
|
220
|
+
with pytest.raises(ValidationError) as e:
|
|
221
|
+
b.apply()
|
|
222
|
+
err_msg = repr(e.value)
|
|
223
|
+
for expected in expected_err_msgs:
|
|
224
|
+
assert expected in err_msg
|
canvas_sdk/effects/base.py
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import json
|
|
2
2
|
from typing import Any
|
|
3
3
|
|
|
4
|
-
from
|
|
5
|
-
|
|
4
|
+
from canvas_sdk.base import Model
|
|
6
5
|
from canvas_sdk.effects import Effect, EffectType
|
|
7
6
|
|
|
8
7
|
|
|
9
|
-
class _BaseEffect(
|
|
8
|
+
class _BaseEffect(Model):
|
|
10
9
|
"""
|
|
11
10
|
A Canvas Effect that changes user behavior or autonomously performs activities on behalf of users.
|
|
12
11
|
"""
|
|
@@ -14,8 +13,6 @@ class _BaseEffect(BaseModel):
|
|
|
14
13
|
class Meta:
|
|
15
14
|
effect_type = EffectType.UNKNOWN_EFFECT
|
|
16
15
|
|
|
17
|
-
model_config = ConfigDict(strict=True, validate_assignment=True)
|
|
18
|
-
|
|
19
16
|
@property
|
|
20
17
|
def values(self) -> dict[str, Any]:
|
|
21
18
|
return {}
|
|
@@ -25,4 +22,5 @@ class _BaseEffect(BaseModel):
|
|
|
25
22
|
return {"data": self.values}
|
|
26
23
|
|
|
27
24
|
def apply(self) -> Effect:
|
|
25
|
+
self._validate_before_effect("apply")
|
|
28
26
|
return Effect(type=self.Meta.effect_type, payload=json.dumps(self.effect_payload))
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from pydantic import Field
|
|
5
|
+
|
|
6
|
+
from canvas_sdk.effects.base import EffectType, _BaseEffect
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PatientChartSummaryConfiguration(_BaseEffect):
|
|
10
|
+
"""
|
|
11
|
+
An Effect that will decide which sections appear on the patient's chart summary in Canvas.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
class Meta:
|
|
15
|
+
effect_type = EffectType.SHOW_PATIENT_CHART_SUMMARY_SECTIONS
|
|
16
|
+
|
|
17
|
+
class Section(Enum):
|
|
18
|
+
SOCIAL_DETERMINANTS = "social_determinants"
|
|
19
|
+
GOALS = "goals"
|
|
20
|
+
CONDITIONS = "conditions"
|
|
21
|
+
MEDICATIONS = "medications"
|
|
22
|
+
ALLERGIES = "allergies"
|
|
23
|
+
CARE_TEAMS = "care_teams"
|
|
24
|
+
VITALS = "vitals"
|
|
25
|
+
IMMUNIZATIONS = "immunizations"
|
|
26
|
+
SURGICAL_HISTORY = "surgical_history"
|
|
27
|
+
FAMILY_HISTORY = "family_history"
|
|
28
|
+
|
|
29
|
+
sections: list[Section] = Field(min_length=1)
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def values(self) -> dict[str, Any]:
|
|
33
|
+
"""The PatientChartSummaryConfiguration's values."""
|
|
34
|
+
return {"sections": [s.value for s in self.sections]}
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def effect_payload(self) -> dict[str, Any]:
|
|
38
|
+
"""The payload of the effect."""
|
|
39
|
+
return {"data": self.values}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from canvas_sdk.effects.protocol_card.protocol_card import ProtocolCard, Recommendation
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, ConfigDict
|
|
5
|
+
|
|
6
|
+
from canvas_sdk.effects.base import EffectType, _BaseEffect
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Recommendation(BaseModel):
|
|
10
|
+
"""
|
|
11
|
+
A Recommendation for a Protocol Card.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
model_config = ConfigDict(strict=True, validate_assignment=True)
|
|
15
|
+
|
|
16
|
+
title: str = ""
|
|
17
|
+
button: str = ""
|
|
18
|
+
href: str | None = None
|
|
19
|
+
command: str | None = None
|
|
20
|
+
context: dict | None = None
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def values(self) -> dict:
|
|
24
|
+
"""The ProtocolCard recommendation's values."""
|
|
25
|
+
return {
|
|
26
|
+
"title": self.title,
|
|
27
|
+
"button": self.button,
|
|
28
|
+
"href": self.href,
|
|
29
|
+
"command": {"type": self.command} if self.command else {},
|
|
30
|
+
"context": self.context or {},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ProtocolCard(_BaseEffect):
|
|
35
|
+
"""
|
|
36
|
+
An Effect that will result in a protocol card in Canvas.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
class Status(Enum):
|
|
40
|
+
DUE = "due"
|
|
41
|
+
SATISFIED = "satisfied"
|
|
42
|
+
|
|
43
|
+
class Meta:
|
|
44
|
+
effect_type = EffectType.ADD_OR_UPDATE_PROTOCOL_CARD
|
|
45
|
+
apply_required_fields = ("patient_id", "key")
|
|
46
|
+
|
|
47
|
+
patient_id: str | None = None
|
|
48
|
+
key: str | None = None
|
|
49
|
+
title: str = ""
|
|
50
|
+
narrative: str = ""
|
|
51
|
+
recommendations: list[Recommendation] = []
|
|
52
|
+
status: Status = Status.DUE # type: ignore
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def values(self) -> dict[str, Any]:
|
|
56
|
+
"""The ProtocolCard's values."""
|
|
57
|
+
return {
|
|
58
|
+
"title": self.title,
|
|
59
|
+
"narrative": self.narrative,
|
|
60
|
+
"recommendations": [
|
|
61
|
+
rec.values | {"key": i} for i, rec in enumerate(self.recommendations)
|
|
62
|
+
],
|
|
63
|
+
"status": self.status.value,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def effect_payload(self) -> dict[str, Any]:
|
|
68
|
+
"""The payload of the effect."""
|
|
69
|
+
return {"patient": self.patient_id, "key": self.key, "data": self.values}
|
|
70
|
+
|
|
71
|
+
def add_recommendation(
|
|
72
|
+
self,
|
|
73
|
+
title: str = "",
|
|
74
|
+
button: str = "",
|
|
75
|
+
href: str | None = None,
|
|
76
|
+
command: str | None = None,
|
|
77
|
+
context: dict | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Adds a recommendation to the protocol card's list of recommendations."""
|
|
80
|
+
recommendation = Recommendation(
|
|
81
|
+
title=title, button=button, href=href, command=command, context=context
|
|
82
|
+
)
|
|
83
|
+
self.recommendations.append(recommendation)
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from pydantic import ValidationError
|
|
3
|
+
|
|
4
|
+
from canvas_sdk.commands import (
|
|
5
|
+
AssessCommand,
|
|
6
|
+
DiagnoseCommand,
|
|
7
|
+
GoalCommand,
|
|
8
|
+
HistoryOfPresentIllnessCommand,
|
|
9
|
+
MedicationStatementCommand,
|
|
10
|
+
PlanCommand,
|
|
11
|
+
PrescribeCommand,
|
|
12
|
+
QuestionnaireCommand,
|
|
13
|
+
ReasonForVisitCommand,
|
|
14
|
+
StopMedicationCommand,
|
|
15
|
+
UpdateGoalCommand,
|
|
16
|
+
)
|
|
17
|
+
from canvas_sdk.commands.base import _BaseCommand
|
|
18
|
+
from canvas_sdk.effects.protocol_card import ProtocolCard, Recommendation
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_apply_method_succeeds_with_patient_id_and_key() -> None:
|
|
22
|
+
p = ProtocolCard(patient_id="uuid", key="something-unique")
|
|
23
|
+
applied = p.apply()
|
|
24
|
+
assert (
|
|
25
|
+
applied.payload
|
|
26
|
+
== '{"patient": "uuid", "key": "something-unique", "data": {"title": "", "narrative": "", "recommendations": [], "status": "due"}}'
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_apply_method_raises_error_without_patient_id_and_key() -> None:
|
|
31
|
+
p = ProtocolCard()
|
|
32
|
+
|
|
33
|
+
with pytest.raises(ValidationError) as e:
|
|
34
|
+
p.apply()
|
|
35
|
+
err_msg = repr(e.value)
|
|
36
|
+
|
|
37
|
+
assert "2 validation errors for ProtocolCard" in err_msg
|
|
38
|
+
assert (
|
|
39
|
+
"Field 'patient_id' is required to apply a ProtocolCard [type=missing, input_value=None, input_type=NoneType]"
|
|
40
|
+
in err_msg
|
|
41
|
+
)
|
|
42
|
+
assert (
|
|
43
|
+
"Field 'key' is required to apply a ProtocolCard [type=missing, input_value=None, input_type=NoneType]"
|
|
44
|
+
in err_msg
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.mark.parametrize(
|
|
49
|
+
"init_params,rec1_params,rec2_params",
|
|
50
|
+
[
|
|
51
|
+
(
|
|
52
|
+
{
|
|
53
|
+
"key": "link_rec",
|
|
54
|
+
"patient_id": "patientuuid",
|
|
55
|
+
"title": "This is a test!",
|
|
56
|
+
"narrative": "we should only expect a link and a button",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"title": "this is a link",
|
|
60
|
+
"button": "click this",
|
|
61
|
+
"href": "https://canvasmedical.com/",
|
|
62
|
+
},
|
|
63
|
+
{"title": "second link", "button": "don't click this", "href": "https://google.com/"},
|
|
64
|
+
),
|
|
65
|
+
(
|
|
66
|
+
{
|
|
67
|
+
"key": "command_rec",
|
|
68
|
+
"patient_id": "patientuuid",
|
|
69
|
+
"title": "This is a test for command recommendations!",
|
|
70
|
+
"narrative": "we should only expect buttons to insert commands",
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"title": "this is a command",
|
|
74
|
+
"button": "click this",
|
|
75
|
+
"command": "updategoal",
|
|
76
|
+
"context": {"progress": "none"},
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"title": "another command",
|
|
80
|
+
"button": "hypertension",
|
|
81
|
+
"command": "diagnose",
|
|
82
|
+
"context": {"background": "stuff"},
|
|
83
|
+
},
|
|
84
|
+
),
|
|
85
|
+
(
|
|
86
|
+
{
|
|
87
|
+
"patient_id": "patientuuid",
|
|
88
|
+
"key": "command_rec_with_coding_filter",
|
|
89
|
+
"title": "This is a test for command recommendations with coding filters!",
|
|
90
|
+
"narrative": "we should only expect buttons to insert commands",
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"title": "hypertension",
|
|
94
|
+
"button": "diagnose",
|
|
95
|
+
"command": "diagnose",
|
|
96
|
+
"context": {"background": "hey", "icd10_code": "I10"},
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"title": "fake medication",
|
|
100
|
+
"button": "prescribe",
|
|
101
|
+
"command": "prescribe",
|
|
102
|
+
"context": {"sig": "1pobid"},
|
|
103
|
+
},
|
|
104
|
+
),
|
|
105
|
+
],
|
|
106
|
+
)
|
|
107
|
+
def test_add_recommendations(
|
|
108
|
+
init_params: dict[str, str], rec1_params: dict[str, str], rec2_params: dict[str, str]
|
|
109
|
+
) -> None:
|
|
110
|
+
p = ProtocolCard(**init_params)
|
|
111
|
+
p.add_recommendation(**rec1_params)
|
|
112
|
+
p.recommendations.append(Recommendation(**rec2_params))
|
|
113
|
+
|
|
114
|
+
assert p.values == {
|
|
115
|
+
"title": init_params["title"],
|
|
116
|
+
"narrative": init_params["narrative"],
|
|
117
|
+
"recommendations": [
|
|
118
|
+
{
|
|
119
|
+
"title": rec1_params.get("title", None),
|
|
120
|
+
"button": rec1_params.get("button", None),
|
|
121
|
+
"href": rec1_params.get("href", None),
|
|
122
|
+
"command": {"type": rec1_params["command"]} if "command" in rec1_params else {},
|
|
123
|
+
"context": rec1_params.get("context", {}),
|
|
124
|
+
"key": 0,
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
"title": rec2_params.get("title", None),
|
|
128
|
+
"button": rec2_params.get("button", None),
|
|
129
|
+
"href": rec2_params.get("href", None),
|
|
130
|
+
"command": {"type": rec2_params["command"]} if "command" in rec2_params else {},
|
|
131
|
+
"context": rec2_params.get("context", {}),
|
|
132
|
+
"key": 1,
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
"status": "due",
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@pytest.mark.parametrize(
|
|
140
|
+
"Command,init_params",
|
|
141
|
+
[
|
|
142
|
+
(AssessCommand, {}),
|
|
143
|
+
(DiagnoseCommand, {"icd10_code": "I10"}),
|
|
144
|
+
(GoalCommand, {}),
|
|
145
|
+
(HistoryOfPresentIllnessCommand, {}),
|
|
146
|
+
(MedicationStatementCommand, {"fdb_code": "fakeroo"}),
|
|
147
|
+
(PlanCommand, {}),
|
|
148
|
+
(PrescribeCommand, {"fdb_code": "fake"}),
|
|
149
|
+
(QuestionnaireCommand, {}),
|
|
150
|
+
(ReasonForVisitCommand, {}),
|
|
151
|
+
(StopMedicationCommand, {}),
|
|
152
|
+
(UpdateGoalCommand, {}),
|
|
153
|
+
],
|
|
154
|
+
)
|
|
155
|
+
def test_add_recommendations_from_commands(
|
|
156
|
+
Command: _BaseCommand, init_params: dict[str, str]
|
|
157
|
+
) -> None:
|
|
158
|
+
cmd = Command(**init_params)
|
|
159
|
+
p = ProtocolCard(patient_id="uuid", key="commands")
|
|
160
|
+
p.recommendations.append(cmd.recommend())
|
|
161
|
+
p.recommendations.append(cmd.recommend(title="hello", button="click"))
|
|
162
|
+
p.add_recommendation(
|
|
163
|
+
title="yeehaw", button="click here", command=cmd.Meta.key.lower(), context=init_params
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
rec1, rec2, rec3 = p.values["recommendations"]
|
|
167
|
+
assert rec1["title"] == ""
|
|
168
|
+
assert rec1["button"] == cmd.constantized_key().lower().replace("_", " ")
|
|
169
|
+
assert rec1["href"] is None
|
|
170
|
+
assert rec1["context"] == cmd.values
|
|
171
|
+
assert rec1["command"]["type"] == cmd.Meta.key.lower()
|
|
172
|
+
|
|
173
|
+
assert rec2["title"] == "hello"
|
|
174
|
+
assert rec2["button"] == "click"
|
|
175
|
+
assert rec2["href"] is None
|
|
176
|
+
assert rec2["context"] == cmd.values
|
|
177
|
+
assert rec2["command"]["type"] == cmd.Meta.key.lower()
|
|
178
|
+
|
|
179
|
+
assert rec3["title"] == "yeehaw"
|
|
180
|
+
assert rec3["button"] == "click here"
|
|
181
|
+
assert rec3["href"] is None
|
|
182
|
+
assert rec3["command"]["type"] == cmd.Meta.key.lower()
|
|
183
|
+
for k, v in init_params.items():
|
|
184
|
+
assert rec3["context"][k] == v
|
canvas_sdk/handlers/base.py
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import json
|
|
2
|
+
from typing import TYPE_CHECKING, Any
|
|
3
|
+
|
|
4
|
+
if TYPE_CHECKING:
|
|
5
|
+
from canvas_generated.messages.events_pb2 import Event
|
|
2
6
|
|
|
3
7
|
|
|
4
8
|
class BaseHandler:
|
|
@@ -6,11 +10,20 @@ class BaseHandler:
|
|
|
6
10
|
The class that all handlers inherit from.
|
|
7
11
|
"""
|
|
8
12
|
|
|
9
|
-
|
|
13
|
+
secrets: dict[str, Any]
|
|
14
|
+
target: str
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
event: "Event",
|
|
19
|
+
secrets: dict[str, Any] | None = None,
|
|
20
|
+
) -> None:
|
|
10
21
|
self.event = event
|
|
22
|
+
|
|
11
23
|
try:
|
|
12
24
|
self.context = json.loads(event.context)
|
|
13
25
|
except ValueError:
|
|
14
26
|
self.context = {}
|
|
27
|
+
|
|
15
28
|
self.target = event.target
|
|
16
29
|
self.secrets = secrets or {}
|
canvas_sdk/protocols/base.py
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from canvas_sdk.data.client import GQL_CLIENT
|
|
1
4
|
from canvas_sdk.handlers.base import BaseHandler
|
|
2
5
|
|
|
3
6
|
|
|
@@ -5,3 +8,14 @@ class BaseProtocol(BaseHandler):
|
|
|
5
8
|
"""
|
|
6
9
|
The class that protocols inherit from.
|
|
7
10
|
"""
|
|
11
|
+
|
|
12
|
+
def _beta_run_gql_query(self, query: str, variables: dict | None = None) -> dict[str, Any]:
|
|
13
|
+
return GQL_CLIENT.query(
|
|
14
|
+
query,
|
|
15
|
+
variables=variables,
|
|
16
|
+
extra_args={
|
|
17
|
+
"headers": {
|
|
18
|
+
"Authorization": f'Bearer {self.secrets["graphql_jwt"]}',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from canvas_sdk.protocols.base import BaseProtocol
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ClinicalQualityMeasure(BaseProtocol):
|
|
7
|
+
"""
|
|
8
|
+
The class that ClinicalQualityMeasure protocols inherit from.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
class Meta:
|
|
12
|
+
title: str = ""
|
|
13
|
+
identifiers: list[str] = []
|
|
14
|
+
description: str = ""
|
|
15
|
+
information: str = ""
|
|
16
|
+
references: list[str] = []
|
|
17
|
+
types: list[str] = []
|
|
18
|
+
authors: list[str] = []
|
|
19
|
+
show_in_chart: bool = True
|
|
20
|
+
show_in_population: bool = True
|
|
21
|
+
can_be_snoozed: bool = True
|
|
22
|
+
is_abstract: bool = False
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def _meta(cls) -> dict[str, Any]:
|
|
26
|
+
"""
|
|
27
|
+
Meta properties of the protocol in dictionary form.
|
|
28
|
+
"""
|
|
29
|
+
base_meta = {
|
|
30
|
+
k: v for k, v in ClinicalQualityMeasure.Meta.__dict__.items() if not k.startswith("__")
|
|
31
|
+
}
|
|
32
|
+
class_meta = {k: v for k, v in cls.Meta.__dict__.items() if not k.startswith("__")}
|
|
33
|
+
|
|
34
|
+
return base_meta | class_meta
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def protocol_key(cls) -> str:
|
|
38
|
+
"""
|
|
39
|
+
External key used to identify the protocol.
|
|
40
|
+
"""
|
|
41
|
+
return cls.__name__
|
canvas_sdk/utils/db.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Any
|
|
3
|
+
from urllib import parse
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_database_dict_from_url() -> dict[str, Any]:
|
|
7
|
+
"""Retrieves the database URL for the data module connection formatted for Django settings."""
|
|
8
|
+
parsed_url = parse.urlparse(os.getenv("DATABASE_URL"))
|
|
9
|
+
db_name = parsed_url.path[1:]
|
|
10
|
+
return {
|
|
11
|
+
"ENGINE": "django.db.backends.postgresql",
|
|
12
|
+
"NAME": db_name,
|
|
13
|
+
"USER": os.getenv("CANVAS_SDK_DATABASE_ROLE"),
|
|
14
|
+
"PASSWORD": os.getenv("CANVAS_SDK_DATABASE_ROLE_PASSWORD"),
|
|
15
|
+
"HOST": parsed_url.hostname,
|
|
16
|
+
"PORT": parsed_url.port,
|
|
17
|
+
}
|
|
File without changes
|