canvas 0.2.5__py3-none-any.whl → 0.2.11__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.2.5.dist-info → canvas-0.2.11.dist-info}/METADATA +4 -1
- canvas-0.2.11.dist-info/RECORD +144 -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 +136 -0
- canvas_generated/messages/events_pb2.py +3 -3
- canvas_generated/messages/events_pb2.pyi +614 -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/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/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
- settings.py +43 -0
- canvas-0.2.5.dist-info/RECORD +0 -91
- {canvas-0.2.5.dist-info → canvas-0.2.11.dist-info}/WHEEL +0 -0
- {canvas-0.2.5.dist-info → canvas-0.2.11.dist-info}/entry_points.txt +0 -0
|
@@ -1,11 +1,7 @@
|
|
|
1
|
-
import decimal
|
|
2
|
-
from datetime import datetime
|
|
3
|
-
|
|
4
1
|
import pytest
|
|
5
|
-
import requests
|
|
6
2
|
from pydantic import ValidationError
|
|
3
|
+
from typer.testing import CliRunner
|
|
7
4
|
|
|
8
|
-
import settings
|
|
9
5
|
from canvas_sdk.commands import (
|
|
10
6
|
AssessCommand,
|
|
11
7
|
DiagnoseCommand,
|
|
@@ -19,15 +15,16 @@ from canvas_sdk.commands import (
|
|
|
19
15
|
StopMedicationCommand,
|
|
20
16
|
UpdateGoalCommand,
|
|
21
17
|
)
|
|
22
|
-
from canvas_sdk.commands.
|
|
18
|
+
from canvas_sdk.commands.base import _BaseCommand
|
|
23
19
|
from canvas_sdk.commands.tests.test_utils import (
|
|
24
|
-
MaskedValue,
|
|
25
20
|
fake,
|
|
26
21
|
get_field_type,
|
|
27
22
|
raises_none_error_for_effect_method,
|
|
28
23
|
raises_wrong_type_error,
|
|
29
24
|
)
|
|
30
25
|
|
|
26
|
+
runner = CliRunner()
|
|
27
|
+
|
|
31
28
|
|
|
32
29
|
@pytest.mark.parametrize(
|
|
33
30
|
"Command,fields_to_test",
|
|
@@ -50,7 +47,7 @@ from canvas_sdk.commands.tests.test_utils import (
|
|
|
50
47
|
),
|
|
51
48
|
(HistoryOfPresentIllnessCommand, ("narrative",)),
|
|
52
49
|
(MedicationStatementCommand, ("fdb_code", "sig")),
|
|
53
|
-
(PlanCommand, ("narrative", "
|
|
50
|
+
(PlanCommand, ("narrative", "command_uuid")),
|
|
54
51
|
(
|
|
55
52
|
PrescribeCommand,
|
|
56
53
|
(
|
|
@@ -82,19 +79,7 @@ from canvas_sdk.commands.tests.test_utils import (
|
|
|
82
79
|
],
|
|
83
80
|
)
|
|
84
81
|
def test_command_raises_generic_error_when_kwarg_given_incorrect_type(
|
|
85
|
-
Command:
|
|
86
|
-
AssessCommand
|
|
87
|
-
| DiagnoseCommand
|
|
88
|
-
| GoalCommand
|
|
89
|
-
| HistoryOfPresentIllnessCommand
|
|
90
|
-
| MedicationStatementCommand
|
|
91
|
-
| PlanCommand
|
|
92
|
-
| PrescribeCommand
|
|
93
|
-
| QuestionnaireCommand
|
|
94
|
-
| ReasonForVisitCommand
|
|
95
|
-
| StopMedicationCommand
|
|
96
|
-
| UpdateGoalCommand
|
|
97
|
-
),
|
|
82
|
+
Command: _BaseCommand,
|
|
98
83
|
fields_to_test: tuple[str],
|
|
99
84
|
) -> None:
|
|
100
85
|
for field in fields_to_test:
|
|
@@ -109,29 +94,22 @@ def test_command_raises_generic_error_when_kwarg_given_incorrect_type(
|
|
|
109
94
|
[
|
|
110
95
|
(
|
|
111
96
|
PlanCommand,
|
|
112
|
-
{"narrative": "yo", "
|
|
97
|
+
{"narrative": "yo", "note_uuid": 1},
|
|
113
98
|
"1 validation error for PlanCommand\nnote_uuid\n Input should be a valid string [type=string_type",
|
|
114
|
-
{"narrative": "yo", "note_uuid": "00000000-0000-0000-0000-000000000000"
|
|
99
|
+
{"narrative": "yo", "note_uuid": "00000000-0000-0000-0000-000000000000"},
|
|
115
100
|
),
|
|
116
101
|
(
|
|
117
102
|
PlanCommand,
|
|
118
|
-
{"narrative": "yo", "
|
|
103
|
+
{"narrative": "yo", "note_uuid": "5", "command_uuid": 5},
|
|
119
104
|
"1 validation error for PlanCommand\ncommand_uuid\n Input should be a valid string [type=string_type",
|
|
120
|
-
{"narrative": "yo", "
|
|
121
|
-
),
|
|
122
|
-
(
|
|
123
|
-
PlanCommand,
|
|
124
|
-
{"narrative": "yo", "note_uuid": "5", "command_uuid": "4", "user_id": "5"},
|
|
125
|
-
"1 validation error for PlanCommand\nuser_id\n Input should be a valid integer [type=int_type",
|
|
126
|
-
{"narrative": "yo", "note_uuid": "5", "command_uuid": "4", "user_id": 5},
|
|
105
|
+
{"narrative": "yo", "note_uuid": "5", "command_uuid": "5"},
|
|
127
106
|
),
|
|
128
107
|
(
|
|
129
108
|
ReasonForVisitCommand,
|
|
130
|
-
{"note_uuid": "00000000-0000-0000-0000-000000000000", "
|
|
109
|
+
{"note_uuid": "00000000-0000-0000-0000-000000000000", "structured": True},
|
|
131
110
|
"1 validation error for ReasonForVisitCommand\n Structured RFV should have a coding",
|
|
132
111
|
{
|
|
133
112
|
"note_uuid": "00000000-0000-0000-0000-000000000000",
|
|
134
|
-
"user_id": 1,
|
|
135
113
|
"structured": False,
|
|
136
114
|
},
|
|
137
115
|
),
|
|
@@ -139,71 +117,64 @@ def test_command_raises_generic_error_when_kwarg_given_incorrect_type(
|
|
|
139
117
|
ReasonForVisitCommand,
|
|
140
118
|
{
|
|
141
119
|
"note_uuid": "00000000-0000-0000-0000-000000000000",
|
|
142
|
-
"user_id": 1,
|
|
143
120
|
"coding": {"code": "x"},
|
|
144
121
|
},
|
|
145
122
|
"1 validation error for ReasonForVisitCommand\ncoding.system\n Field required [type=missing",
|
|
146
|
-
{"note_uuid": "00000000-0000-0000-0000-000000000000"
|
|
123
|
+
{"note_uuid": "00000000-0000-0000-0000-000000000000"},
|
|
147
124
|
),
|
|
148
125
|
(
|
|
149
126
|
ReasonForVisitCommand,
|
|
150
127
|
{
|
|
151
128
|
"note_uuid": "00000000-0000-0000-0000-000000000000",
|
|
152
|
-
"user_id": 1,
|
|
153
129
|
"coding": {"code": 1, "system": "y"},
|
|
154
130
|
},
|
|
155
131
|
"1 validation error for ReasonForVisitCommand\ncoding.code\n Input should be a valid string [type=string_type",
|
|
156
|
-
{"note_uuid": "00000000-0000-0000-0000-000000000000"
|
|
132
|
+
{"note_uuid": "00000000-0000-0000-0000-000000000000"},
|
|
157
133
|
),
|
|
158
134
|
(
|
|
159
135
|
ReasonForVisitCommand,
|
|
160
136
|
{
|
|
161
137
|
"note_uuid": "00000000-0000-0000-0000-000000000000",
|
|
162
|
-
"user_id": 1,
|
|
163
138
|
"coding": {"code": None, "system": "y"},
|
|
164
139
|
},
|
|
165
140
|
"1 validation error for ReasonForVisitCommand\ncoding.code\n Input should be a valid string [type=string_type",
|
|
166
|
-
{"note_uuid": "00000000-0000-0000-0000-000000000000"
|
|
141
|
+
{"note_uuid": "00000000-0000-0000-0000-000000000000"},
|
|
167
142
|
),
|
|
168
143
|
(
|
|
169
144
|
ReasonForVisitCommand,
|
|
170
145
|
{
|
|
171
146
|
"note_uuid": "00000000-0000-0000-0000-000000000000",
|
|
172
|
-
"user_id": 1,
|
|
173
147
|
"coding": {"system": "y"},
|
|
174
148
|
},
|
|
175
149
|
"1 validation error for ReasonForVisitCommand\ncoding.code\n Field required [type=missing",
|
|
176
|
-
{"note_uuid": "00000000-0000-0000-0000-000000000000"
|
|
150
|
+
{"note_uuid": "00000000-0000-0000-0000-000000000000"},
|
|
177
151
|
),
|
|
178
152
|
(
|
|
179
153
|
ReasonForVisitCommand,
|
|
180
154
|
{
|
|
181
155
|
"note_uuid": "00000000-0000-0000-0000-000000000000",
|
|
182
|
-
"user_id": 1,
|
|
183
156
|
"coding": {"code": "x", "system": 1},
|
|
184
157
|
},
|
|
185
158
|
"1 validation error for ReasonForVisitCommand\ncoding.system\n Input should be a valid string [type=string_type",
|
|
186
|
-
{"note_uuid": "00000000-0000-0000-0000-000000000000"
|
|
159
|
+
{"note_uuid": "00000000-0000-0000-0000-000000000000"},
|
|
187
160
|
),
|
|
188
161
|
(
|
|
189
162
|
ReasonForVisitCommand,
|
|
190
163
|
{
|
|
191
164
|
"note_uuid": "00000000-0000-0000-0000-000000000000",
|
|
192
|
-
"user_id": 1,
|
|
193
165
|
"coding": {"code": "x", "system": None},
|
|
194
166
|
},
|
|
195
167
|
"1 validation error for ReasonForVisitCommand\ncoding.system\n Input should be a valid string [type=string_type",
|
|
196
|
-
{"note_uuid": "00000000-0000-0000-0000-000000000000"
|
|
168
|
+
{"note_uuid": "00000000-0000-0000-0000-000000000000"},
|
|
197
169
|
),
|
|
198
170
|
(
|
|
199
171
|
ReasonForVisitCommand,
|
|
200
172
|
{
|
|
201
173
|
"note_uuid": "00000000-0000-0000-0000-000000000000",
|
|
202
|
-
"user_id": 1,
|
|
203
174
|
"coding": {"code": "x", "system": "y", "display": 1},
|
|
204
175
|
},
|
|
205
176
|
"1 validation error for ReasonForVisitCommand\ncoding.display\n Input should be a valid string [type=string_type",
|
|
206
|
-
{"note_uuid": "00000000-0000-0000-0000-000000000000"
|
|
177
|
+
{"note_uuid": "00000000-0000-0000-0000-000000000000"},
|
|
207
178
|
),
|
|
208
179
|
],
|
|
209
180
|
)
|
|
@@ -251,7 +222,7 @@ def test_command_raises_specific_error_when_kwarg_given_incorrect_type(
|
|
|
251
222
|
),
|
|
252
223
|
(HistoryOfPresentIllnessCommand, ("narrative",)),
|
|
253
224
|
(MedicationStatementCommand, ("fdb_code", "sig")),
|
|
254
|
-
(PlanCommand, ("narrative", "
|
|
225
|
+
(PlanCommand, ("narrative", "command_uuid", "note_uuid")),
|
|
255
226
|
(
|
|
256
227
|
PrescribeCommand,
|
|
257
228
|
(
|
|
@@ -284,19 +255,7 @@ def test_command_raises_specific_error_when_kwarg_given_incorrect_type(
|
|
|
284
255
|
],
|
|
285
256
|
)
|
|
286
257
|
def test_command_allows_kwarg_with_correct_type(
|
|
287
|
-
Command:
|
|
288
|
-
AssessCommand
|
|
289
|
-
| DiagnoseCommand
|
|
290
|
-
| GoalCommand
|
|
291
|
-
| HistoryOfPresentIllnessCommand
|
|
292
|
-
| MedicationStatementCommand
|
|
293
|
-
| PlanCommand
|
|
294
|
-
| PrescribeCommand
|
|
295
|
-
| QuestionnaireCommand
|
|
296
|
-
| ReasonForVisitCommand
|
|
297
|
-
| StopMedicationCommand
|
|
298
|
-
| UpdateGoalCommand
|
|
299
|
-
),
|
|
258
|
+
Command: _BaseCommand,
|
|
300
259
|
fields_to_test: tuple[str],
|
|
301
260
|
) -> None:
|
|
302
261
|
schema = Command.model_json_schema()
|
|
@@ -323,136 +282,3 @@ def test_command_allows_kwarg_with_correct_type(
|
|
|
323
282
|
cmd = Command(**base)
|
|
324
283
|
effect = getattr(cmd, method)()
|
|
325
284
|
assert effect is not None
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
@pytest.fixture(scope="session")
|
|
329
|
-
def token() -> MaskedValue:
|
|
330
|
-
return MaskedValue(
|
|
331
|
-
requests.post(
|
|
332
|
-
f"{settings.INTEGRATION_TEST_URL}/auth/token/",
|
|
333
|
-
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
334
|
-
data={
|
|
335
|
-
"grant_type": "client_credentials",
|
|
336
|
-
"client_id": settings.INTEGRATION_TEST_CLIENT_ID,
|
|
337
|
-
"client_secret": settings.INTEGRATION_TEST_CLIENT_SECRET,
|
|
338
|
-
},
|
|
339
|
-
).json()["access_token"]
|
|
340
|
-
)
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
@pytest.fixture
|
|
344
|
-
def note_uuid(token: MaskedValue) -> str:
|
|
345
|
-
headers = {
|
|
346
|
-
"Authorization": f"Bearer {token.value}",
|
|
347
|
-
"Content-Type": "application/json",
|
|
348
|
-
"Accept": "application/json",
|
|
349
|
-
}
|
|
350
|
-
data = {
|
|
351
|
-
"patient": 1,
|
|
352
|
-
"provider": 1,
|
|
353
|
-
"note_type": "office",
|
|
354
|
-
"note_type_version": 1,
|
|
355
|
-
"lastModifiedBySessionKey": "8fee3c03a525cebee1d8a6b8e63dd4dg",
|
|
356
|
-
}
|
|
357
|
-
note = requests.post(
|
|
358
|
-
f"{settings.INTEGRATION_TEST_URL}/api/Note/", headers=headers, json=data
|
|
359
|
-
).json()
|
|
360
|
-
return note["externallyExposableId"]
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
@pytest.fixture
|
|
364
|
-
def command_type_map() -> dict[str, type]:
|
|
365
|
-
return {
|
|
366
|
-
"AutocompleteField": str,
|
|
367
|
-
"MultiLineTextField": str,
|
|
368
|
-
"TextField": str,
|
|
369
|
-
"ChoiceField": str,
|
|
370
|
-
"DateField": datetime,
|
|
371
|
-
"ApproximateDateField": datetime,
|
|
372
|
-
"IntegerField": int,
|
|
373
|
-
"DecimalField": decimal.Decimal,
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
@pytest.mark.integtest
|
|
378
|
-
@pytest.mark.parametrize(
|
|
379
|
-
"Command",
|
|
380
|
-
[
|
|
381
|
-
(AssessCommand),
|
|
382
|
-
(DiagnoseCommand),
|
|
383
|
-
(GoalCommand),
|
|
384
|
-
(HistoryOfPresentIllnessCommand),
|
|
385
|
-
(MedicationStatementCommand),
|
|
386
|
-
(PlanCommand),
|
|
387
|
-
(PrescribeCommand),
|
|
388
|
-
(QuestionnaireCommand),
|
|
389
|
-
(ReasonForVisitCommand),
|
|
390
|
-
(StopMedicationCommand),
|
|
391
|
-
(UpdateGoalCommand),
|
|
392
|
-
],
|
|
393
|
-
)
|
|
394
|
-
def test_command_schema_matches_command_api(
|
|
395
|
-
token: MaskedValue,
|
|
396
|
-
command_type_map: dict[str, str],
|
|
397
|
-
note_uuid: str,
|
|
398
|
-
Command: (
|
|
399
|
-
AssessCommand
|
|
400
|
-
| DiagnoseCommand
|
|
401
|
-
| GoalCommand
|
|
402
|
-
| HistoryOfPresentIllnessCommand
|
|
403
|
-
| MedicationStatementCommand
|
|
404
|
-
| PlanCommand
|
|
405
|
-
| PrescribeCommand
|
|
406
|
-
| QuestionnaireCommand
|
|
407
|
-
| ReasonForVisitCommand
|
|
408
|
-
| StopMedicationCommand
|
|
409
|
-
| UpdateGoalCommand
|
|
410
|
-
),
|
|
411
|
-
) -> None:
|
|
412
|
-
# first create the command in the new note
|
|
413
|
-
data = {"noteKey": note_uuid, "schemaKey": Command.Meta.key}
|
|
414
|
-
headers = {"Authorization": f"Bearer {token.value}"}
|
|
415
|
-
url = f"{settings.INTEGRATION_TEST_URL}/core/api/v1/commands/"
|
|
416
|
-
command_resp = requests.post(url, headers=headers, data=data).json()
|
|
417
|
-
assert "uuid" in command_resp
|
|
418
|
-
command_uuid = command_resp["uuid"]
|
|
419
|
-
|
|
420
|
-
# next, request the fields of the newly created command
|
|
421
|
-
url = f"{settings.INTEGRATION_TEST_URL}/core/api/v1/commands/{command_uuid}/fields/"
|
|
422
|
-
command_fields_resp = requests.get(url, headers=headers).json()
|
|
423
|
-
assert command_fields_resp["schema"] == Command.Meta.key
|
|
424
|
-
|
|
425
|
-
command_fields = command_fields_resp["fields"]
|
|
426
|
-
if Command.Meta.key == "questionnaire":
|
|
427
|
-
# questionnaire's fields vary per questionnaire, so just check the first two fields which never vary
|
|
428
|
-
command_fields = command_fields[:2]
|
|
429
|
-
expected_fields = Command.command_schema()
|
|
430
|
-
assert len(command_fields) == len(expected_fields)
|
|
431
|
-
|
|
432
|
-
for actual_field in command_fields:
|
|
433
|
-
name = actual_field["name"]
|
|
434
|
-
assert name in expected_fields
|
|
435
|
-
expected_field = expected_fields[name]
|
|
436
|
-
|
|
437
|
-
assert expected_field["required"] == actual_field["required"]
|
|
438
|
-
|
|
439
|
-
expected_type = expected_field["type"]
|
|
440
|
-
if expected_type is Coding:
|
|
441
|
-
expected_type = expected_type.__annotations__["code"]
|
|
442
|
-
|
|
443
|
-
actual_type = command_type_map.get(actual_field["type"])
|
|
444
|
-
if actual_field["type"] == "AutocompleteField" and name[-1] == "s":
|
|
445
|
-
# this condition initially created for Prescribe.indications,
|
|
446
|
-
# but could apply to other AutocompleteField fields that are lists
|
|
447
|
-
# making the assumption here that if the field ends in 's' (like indications), it is a list
|
|
448
|
-
actual_type = list[actual_type] # type: ignore
|
|
449
|
-
|
|
450
|
-
assert expected_type == actual_type
|
|
451
|
-
|
|
452
|
-
if (choices := actual_field["choices"]) is None:
|
|
453
|
-
assert expected_field["choices"] is None
|
|
454
|
-
continue
|
|
455
|
-
|
|
456
|
-
assert len(expected_field["choices"]) == len(choices)
|
|
457
|
-
for choice in choices:
|
|
458
|
-
assert choice["value"] in expected_field["choices"]
|
|
@@ -13,6 +13,7 @@ class AddBannerAlert(_BaseEffect):
|
|
|
13
13
|
|
|
14
14
|
class Meta:
|
|
15
15
|
effect_type = EffectType.ADD_BANNER_ALERT
|
|
16
|
+
apply_required_fields = ("patient_id", "key", "narrative", "placement", "intent")
|
|
16
17
|
|
|
17
18
|
class Placement(Enum):
|
|
18
19
|
CHART = "chart"
|
|
@@ -26,11 +27,11 @@ class AddBannerAlert(_BaseEffect):
|
|
|
26
27
|
WARNING = "warning"
|
|
27
28
|
ALERT = "alert"
|
|
28
29
|
|
|
29
|
-
patient_id: str
|
|
30
|
-
key: str
|
|
31
|
-
narrative: str = Field(max_length=90)
|
|
32
|
-
placement: list[Placement] = Field(min_length=1)
|
|
33
|
-
intent: Intent
|
|
30
|
+
patient_id: str | None = None
|
|
31
|
+
key: str | None = None
|
|
32
|
+
narrative: str | None = Field(max_length=90, default=None)
|
|
33
|
+
placement: list[Placement] | None = Field(min_length=1, default=None)
|
|
34
|
+
intent: Intent | None = None
|
|
34
35
|
href: str | None = None
|
|
35
36
|
|
|
36
37
|
@property
|
|
@@ -38,8 +39,8 @@ class AddBannerAlert(_BaseEffect):
|
|
|
38
39
|
"""The BannerAlert's values."""
|
|
39
40
|
return {
|
|
40
41
|
"narrative": self.narrative,
|
|
41
|
-
"placement": [p.value for p in self.placement],
|
|
42
|
-
"intent": self.intent.value,
|
|
42
|
+
"placement": [p.value for p in self.placement] if self.placement else None,
|
|
43
|
+
"intent": self.intent.value if self.intent else None,
|
|
43
44
|
"href": self.href,
|
|
44
45
|
}
|
|
45
46
|
|
|
@@ -10,9 +10,10 @@ class RemoveBannerAlert(_BaseEffect):
|
|
|
10
10
|
|
|
11
11
|
class Meta:
|
|
12
12
|
effect_type = EffectType.REMOVE_BANNER_ALERT
|
|
13
|
+
apply_required_fields = ("patient_id", "key")
|
|
13
14
|
|
|
14
|
-
patient_id: str
|
|
15
|
-
key: str
|
|
15
|
+
patient_id: str | None = None
|
|
16
|
+
key: str | None = None
|
|
16
17
|
|
|
17
18
|
@property
|
|
18
19
|
def effect_payload(self) -> dict[str, Any]:
|
|
@@ -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
|