canvas 0.1.12b0__py3-none-any.whl → 0.1.15__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.12b0.dist-info → canvas-0.1.15.dist-info}/METADATA +43 -2
- canvas-0.1.15.dist-info/RECORD +95 -0
- {canvas-0.1.12b0.dist-info → canvas-0.1.15.dist-info}/WHEEL +1 -1
- canvas_cli/apps/plugin/__init__.py +3 -1
- canvas_cli/apps/plugin/plugin.py +74 -0
- canvas_cli/main.py +5 -3
- canvas_cli/templates/plugins/default/{{ cookiecutter.__project_slug }}/protocols/my_protocol.py +3 -0
- canvas_generated/data_access_layer/data_access_layer_pb2.py +30 -0
- canvas_generated/data_access_layer/data_access_layer_pb2.pyi +23 -0
- canvas_generated/data_access_layer/data_access_layer_pb2_grpc.py +66 -0
- canvas_generated/messages/effects_pb2.py +28 -0
- canvas_generated/messages/effects_pb2.pyi +147 -0
- canvas_generated/messages/events_pb2.py +31 -0
- {generated → canvas_generated}/messages/events_pb2.pyi +3 -1
- {generated → canvas_generated}/messages/plugins_pb2.py +7 -7
- canvas_generated/services/plugin_runner_pb2.py +28 -0
- {generated → canvas_generated}/services/plugin_runner_pb2.pyi +2 -2
- {generated → canvas_generated}/services/plugin_runner_pb2_grpc.py +14 -14
- canvas_sdk/base.py +45 -0
- canvas_sdk/commands/base.py +61 -41
- canvas_sdk/commands/commands/assess.py +6 -2
- canvas_sdk/commands/commands/diagnose.py +4 -14
- canvas_sdk/commands/commands/goal.py +3 -2
- canvas_sdk/commands/commands/history_present_illness.py +2 -1
- canvas_sdk/commands/commands/medication_statement.py +6 -2
- canvas_sdk/commands/commands/plan.py +2 -1
- canvas_sdk/commands/commands/prescribe.py +24 -11
- canvas_sdk/commands/commands/questionnaire.py +6 -2
- canvas_sdk/commands/commands/reason_for_visit.py +13 -6
- canvas_sdk/commands/commands/stop_medication.py +6 -2
- canvas_sdk/commands/commands/update_goal.py +4 -1
- canvas_sdk/commands/tests/test_utils.py +31 -64
- canvas_sdk/commands/tests/tests.py +116 -65
- canvas_sdk/data/__init__.py +1 -0
- canvas_sdk/data/base.py +22 -0
- canvas_sdk/data/data_access_layer_client.py +95 -0
- canvas_sdk/data/exceptions.py +16 -0
- canvas_sdk/data/patient.py +26 -0
- canvas_sdk/data/staff.py +6 -0
- canvas_sdk/data/task.py +60 -0
- canvas_sdk/effects/__init__.py +1 -1
- canvas_sdk/effects/banner_alert/__init__.py +2 -0
- canvas_sdk/effects/banner_alert/add_banner_alert.py +49 -0
- canvas_sdk/effects/banner_alert/remove_banner_alert.py +20 -0
- canvas_sdk/effects/base.py +4 -6
- canvas_sdk/events/__init__.py +1 -1
- canvas_sdk/handlers/__init__.py +1 -0
- canvas_sdk/handlers/base.py +16 -0
- canvas_sdk/handlers/cron_task.py +35 -0
- canvas_sdk/protocols/base.py +2 -11
- canvas_sdk/utils/stats.py +27 -0
- logger/__init__.py +2 -0
- logger/logger.py +48 -0
- canvas-0.1.12b0.dist-info/RECORD +0 -78
- canvas_sdk/effects/banner_alert/banner_alert.py +0 -37
- canvas_sdk/effects/banner_alert/constants.py +0 -19
- generated/messages/effects_pb2.py +0 -28
- generated/messages/effects_pb2.pyi +0 -25
- generated/messages/events_pb2.py +0 -31
- generated/services/plugin_runner_pb2.py +0 -28
- {canvas-0.1.12b0.dist-info → canvas-0.1.15.dist-info}/entry_points.txt +0 -0
- {generated → canvas_generated}/messages/effects_pb2_grpc.py +0 -0
- {generated → canvas_generated}/messages/events_pb2_grpc.py +0 -0
- {generated → canvas_generated}/messages/plugins_pb2.pyi +0 -0
- {generated → canvas_generated}/messages/plugins_pb2_grpc.py +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
from
|
|
2
|
-
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
from pydantic_core import InitErrorDetails
|
|
3
4
|
|
|
4
5
|
from canvas_sdk.commands.base import _BaseCommand
|
|
5
6
|
from canvas_sdk.commands.constants import Coding
|
|
@@ -16,11 +17,17 @@ class ReasonForVisitCommand(_BaseCommand):
|
|
|
16
17
|
coding: Coding | None = None
|
|
17
18
|
comment: str | None = None
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
def _get_error_details(
|
|
21
|
+
self, method: Literal["originate", "edit", "delete", "commit", "enter_in_error"]
|
|
22
|
+
) -> list[InitErrorDetails]:
|
|
23
|
+
errors = super()._get_error_details(method)
|
|
21
24
|
if self.structured and not self.coding:
|
|
22
|
-
|
|
23
|
-
|
|
25
|
+
errors.append(
|
|
26
|
+
self._create_error_detail(
|
|
27
|
+
"value", f"Structured RFV should have a coding.", self.coding
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
return errors
|
|
24
31
|
|
|
25
32
|
@classmethod
|
|
26
33
|
def command_schema(cls) -> dict:
|
|
@@ -1,15 +1,19 @@
|
|
|
1
|
-
from canvas_sdk.commands.base import _BaseCommand
|
|
2
1
|
from pydantic import Field
|
|
3
2
|
|
|
3
|
+
from canvas_sdk.commands.base import _BaseCommand
|
|
4
|
+
|
|
4
5
|
|
|
5
6
|
class StopMedicationCommand(_BaseCommand):
|
|
6
7
|
"""A class for managing a StopMedication command within a specific note."""
|
|
7
8
|
|
|
8
9
|
class Meta:
|
|
9
10
|
key = "stopMedication"
|
|
11
|
+
originate_required_fields = ("medication_id",)
|
|
10
12
|
|
|
11
13
|
# how do we make sure this is a valid medication_id for the patient?
|
|
12
|
-
medication_id: str = Field(
|
|
14
|
+
medication_id: str | None = Field(
|
|
15
|
+
default=None, json_schema_extra={"commands_api_name": "medication"}
|
|
16
|
+
)
|
|
13
17
|
rationale: str | None = None
|
|
14
18
|
|
|
15
19
|
@property
|
|
@@ -11,6 +11,7 @@ class UpdateGoalCommand(_BaseCommand):
|
|
|
11
11
|
|
|
12
12
|
class Meta:
|
|
13
13
|
key = "updateGoal"
|
|
14
|
+
originate_required_fields = ("goal_id",)
|
|
14
15
|
|
|
15
16
|
class AchievementStatus(Enum):
|
|
16
17
|
IN_PROGRESS = "in-progress"
|
|
@@ -28,7 +29,9 @@ class UpdateGoalCommand(_BaseCommand):
|
|
|
28
29
|
MEDIUM = "medium-priority"
|
|
29
30
|
LOW = "low-priority"
|
|
30
31
|
|
|
31
|
-
goal_id: str = Field(
|
|
32
|
+
goal_id: str | None = Field(
|
|
33
|
+
default=None, json_schema_extra={"commands_api_name": "goal_statement"}
|
|
34
|
+
)
|
|
32
35
|
due_date: datetime | None = None
|
|
33
36
|
achievement_status: AchievementStatus | None = None
|
|
34
37
|
priority: Priority | None = None
|
|
@@ -22,6 +22,17 @@ from canvas_sdk.commands import (
|
|
|
22
22
|
from canvas_sdk.commands.constants import Coding
|
|
23
23
|
|
|
24
24
|
|
|
25
|
+
class MaskedValue:
|
|
26
|
+
def __init__(self, value):
|
|
27
|
+
self.value = value
|
|
28
|
+
|
|
29
|
+
def __repr__(self) -> str:
|
|
30
|
+
return "MaskedValue(********)"
|
|
31
|
+
|
|
32
|
+
def __str___(self) -> str:
|
|
33
|
+
return "*******"
|
|
34
|
+
|
|
35
|
+
|
|
25
36
|
def get_field_type_unformatted(field_props: dict[str, Any]) -> str:
|
|
26
37
|
if t := field_props.get("type"):
|
|
27
38
|
return field_props.get("format") or t
|
|
@@ -77,35 +88,7 @@ def fake(
|
|
|
77
88
|
return random.choice([e for e in getattr(Command, t)])
|
|
78
89
|
|
|
79
90
|
|
|
80
|
-
def
|
|
81
|
-
base: dict,
|
|
82
|
-
Command: (
|
|
83
|
-
AssessCommand
|
|
84
|
-
| DiagnoseCommand
|
|
85
|
-
| GoalCommand
|
|
86
|
-
| HistoryOfPresentIllnessCommand
|
|
87
|
-
| MedicationStatementCommand
|
|
88
|
-
| PlanCommand
|
|
89
|
-
| PrescribeCommand
|
|
90
|
-
| QuestionnaireCommand
|
|
91
|
-
| ReasonForVisitCommand
|
|
92
|
-
| StopMedicationCommand
|
|
93
|
-
),
|
|
94
|
-
field: str,
|
|
95
|
-
) -> None:
|
|
96
|
-
err_kwargs = base.copy()
|
|
97
|
-
err_kwargs.pop(field)
|
|
98
|
-
with pytest.raises(ValidationError) as e:
|
|
99
|
-
Command(**err_kwargs)
|
|
100
|
-
err_msg = repr(e.value)
|
|
101
|
-
assert (
|
|
102
|
-
f"1 validation error for {Command.__name__}\n{field}\n Field required [type=missing"
|
|
103
|
-
in err_msg
|
|
104
|
-
)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
def raises_none_error(
|
|
108
|
-
base: dict,
|
|
91
|
+
def raises_wrong_type_error(
|
|
109
92
|
Command: (
|
|
110
93
|
AssessCommand
|
|
111
94
|
| DiagnoseCommand
|
|
@@ -122,21 +105,24 @@ def raises_none_error(
|
|
|
122
105
|
) -> None:
|
|
123
106
|
field_props = Command.model_json_schema()["properties"][field]
|
|
124
107
|
field_type = get_field_type(field_props)
|
|
108
|
+
wrong_field_type = "integer" if field_type == "string" else "string"
|
|
125
109
|
|
|
126
110
|
with pytest.raises(ValidationError) as e1:
|
|
127
|
-
err_kwargs =
|
|
111
|
+
err_kwargs = {field: fake({"type": wrong_field_type}, Command)}
|
|
128
112
|
Command(**err_kwargs)
|
|
129
113
|
err_msg1 = repr(e1.value)
|
|
130
114
|
|
|
131
|
-
valid_kwargs =
|
|
115
|
+
valid_kwargs = {field: fake(field_props, Command)}
|
|
132
116
|
cmd = Command(**valid_kwargs)
|
|
117
|
+
err_value = fake({"type": wrong_field_type}, Command)
|
|
133
118
|
with pytest.raises(ValidationError) as e2:
|
|
134
|
-
setattr(cmd, field,
|
|
119
|
+
setattr(cmd, field, err_value)
|
|
135
120
|
err_msg2 = repr(e2.value)
|
|
136
121
|
|
|
137
122
|
assert f"1 validation error for {Command.__name__}\n{field}" in err_msg1
|
|
138
123
|
assert f"1 validation error for {Command.__name__}\n{field}" in err_msg2
|
|
139
124
|
|
|
125
|
+
field_type = "dictionary" if field_type == "Coding" else field_type
|
|
140
126
|
if field_type == "number":
|
|
141
127
|
assert f"Input should be an instance of Decimal" in err_msg1
|
|
142
128
|
assert f"Input should be an instance of Decimal" in err_msg2
|
|
@@ -148,8 +134,7 @@ def raises_none_error(
|
|
|
148
134
|
assert f"Input should be a valid {field_type}" in err_msg2
|
|
149
135
|
|
|
150
136
|
|
|
151
|
-
def
|
|
152
|
-
base: dict,
|
|
137
|
+
def raises_none_error_for_effect_method(
|
|
153
138
|
Command: (
|
|
154
139
|
AssessCommand
|
|
155
140
|
| DiagnoseCommand
|
|
@@ -162,34 +147,16 @@ def raises_wrong_type_error(
|
|
|
162
147
|
| ReasonForVisitCommand
|
|
163
148
|
| StopMedicationCommand
|
|
164
149
|
),
|
|
165
|
-
|
|
150
|
+
method: str,
|
|
166
151
|
) -> None:
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
err_value = fake({"type": wrong_field_type}, Command)
|
|
179
|
-
with pytest.raises(ValidationError) as e2:
|
|
180
|
-
setattr(cmd, field, err_value)
|
|
181
|
-
err_msg2 = repr(e2.value)
|
|
182
|
-
|
|
183
|
-
assert f"1 validation error for {Command.__name__}\n{field}" in err_msg1
|
|
184
|
-
assert f"1 validation error for {Command.__name__}\n{field}" in err_msg2
|
|
185
|
-
|
|
186
|
-
field_type = "dictionary" if field_type == "Coding" else field_type
|
|
187
|
-
if field_type == "number":
|
|
188
|
-
assert f"Input should be an instance of Decimal" in err_msg1
|
|
189
|
-
assert f"Input should be an instance of Decimal" in err_msg2
|
|
190
|
-
elif field_type[0].isupper():
|
|
191
|
-
assert f"Input should be an instance of {Command.__name__}.{field_type}" in err_msg1
|
|
192
|
-
assert f"Input should be an instance of {Command.__name__}.{field_type}" in err_msg2
|
|
193
|
-
else:
|
|
194
|
-
assert f"Input should be a valid {field_type}" in err_msg1
|
|
195
|
-
assert f"Input should be a valid {field_type}" in err_msg2
|
|
152
|
+
cmd = Command()
|
|
153
|
+
method_required_fields = cmd._get_effect_method_required_fields(method)
|
|
154
|
+
with pytest.raises(ValidationError) as e:
|
|
155
|
+
getattr(cmd, method)()
|
|
156
|
+
e_msg = repr(e.value)
|
|
157
|
+
assert f"{len(method_required_fields)} validation errors for {Command.__name__}" in e_msg
|
|
158
|
+
for f in method_required_fields:
|
|
159
|
+
assert (
|
|
160
|
+
f"Field '{f}' is required to {method.replace('_', ' ')} a command [type=missing, input_value=None, input_type=NoneType]"
|
|
161
|
+
in e_msg
|
|
162
|
+
)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import decimal
|
|
1
2
|
from datetime import datetime
|
|
2
3
|
|
|
3
4
|
import pytest
|
|
@@ -20,10 +21,10 @@ from canvas_sdk.commands import (
|
|
|
20
21
|
)
|
|
21
22
|
from canvas_sdk.commands.constants import Coding
|
|
22
23
|
from canvas_sdk.commands.tests.test_utils import (
|
|
24
|
+
MaskedValue,
|
|
23
25
|
fake,
|
|
24
26
|
get_field_type,
|
|
25
|
-
|
|
26
|
-
raises_none_error,
|
|
27
|
+
raises_none_error_for_effect_method,
|
|
27
28
|
raises_wrong_type_error,
|
|
28
29
|
)
|
|
29
30
|
|
|
@@ -57,7 +58,6 @@ from canvas_sdk.commands.tests.test_utils import (
|
|
|
57
58
|
"icd10_codes",
|
|
58
59
|
"sig",
|
|
59
60
|
"days_supply",
|
|
60
|
-
"quantity_to_dispense",
|
|
61
61
|
"type_to_dispense",
|
|
62
62
|
"refills",
|
|
63
63
|
"substitutions",
|
|
@@ -97,15 +97,11 @@ def test_command_raises_generic_error_when_kwarg_given_incorrect_type(
|
|
|
97
97
|
),
|
|
98
98
|
fields_to_test: tuple[str],
|
|
99
99
|
) -> None:
|
|
100
|
-
schema = Command.model_json_schema()
|
|
101
|
-
schema["required"].append("note_id")
|
|
102
|
-
required_fields = {k: v for k, v in schema["properties"].items() if k in schema["required"]}
|
|
103
|
-
base = {field: fake(props, Command) for field, props in required_fields.items()}
|
|
104
100
|
for field in fields_to_test:
|
|
105
|
-
raises_wrong_type_error(
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
101
|
+
raises_wrong_type_error(Command, field)
|
|
102
|
+
|
|
103
|
+
for method in ["originate", "edit", "delete", "commit", "enter_in_error"]:
|
|
104
|
+
raises_none_error_for_effect_method(Command, method)
|
|
109
105
|
|
|
110
106
|
|
|
111
107
|
@pytest.mark.parametrize(
|
|
@@ -113,69 +109,101 @@ def test_command_raises_generic_error_when_kwarg_given_incorrect_type(
|
|
|
113
109
|
[
|
|
114
110
|
(
|
|
115
111
|
PlanCommand,
|
|
116
|
-
{"narrative": "yo", "user_id": 1},
|
|
117
|
-
"1 validation error for PlanCommand\n
|
|
118
|
-
{"narrative": "yo", "
|
|
112
|
+
{"narrative": "yo", "user_id": 5, "note_uuid": 1},
|
|
113
|
+
"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", "user_id": 1},
|
|
119
115
|
),
|
|
120
116
|
(
|
|
121
117
|
PlanCommand,
|
|
122
|
-
{"narrative": "yo", "user_id":
|
|
123
|
-
"1 validation error for PlanCommand\n
|
|
124
|
-
{"narrative": "yo", "
|
|
118
|
+
{"narrative": "yo", "user_id": 5, "note_uuid": "5", "command_uuid": 5},
|
|
119
|
+
"1 validation error for PlanCommand\ncommand_uuid\n Input should be a valid string [type=string_type",
|
|
120
|
+
{"narrative": "yo", "user_id": 5, "note_uuid": "5", "command_uuid": "5"},
|
|
125
121
|
),
|
|
126
122
|
(
|
|
127
123
|
PlanCommand,
|
|
128
|
-
{"narrative": "yo", "
|
|
129
|
-
"1 validation error for PlanCommand\
|
|
130
|
-
{"narrative": "yo", "
|
|
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},
|
|
131
127
|
),
|
|
132
128
|
(
|
|
133
129
|
ReasonForVisitCommand,
|
|
134
|
-
{"
|
|
135
|
-
"1 validation error for ReasonForVisitCommand\n
|
|
136
|
-
{
|
|
130
|
+
{"note_uuid": "00000000-0000-0000-0000-000000000000", "user_id": 1, "structured": True},
|
|
131
|
+
"1 validation error for ReasonForVisitCommand\n Structured RFV should have a coding",
|
|
132
|
+
{
|
|
133
|
+
"note_uuid": "00000000-0000-0000-0000-000000000000",
|
|
134
|
+
"user_id": 1,
|
|
135
|
+
"structured": False,
|
|
136
|
+
},
|
|
137
137
|
),
|
|
138
138
|
(
|
|
139
139
|
ReasonForVisitCommand,
|
|
140
|
-
{
|
|
140
|
+
{
|
|
141
|
+
"note_uuid": "00000000-0000-0000-0000-000000000000",
|
|
142
|
+
"user_id": 1,
|
|
143
|
+
"coding": {"code": "x"},
|
|
144
|
+
},
|
|
141
145
|
"1 validation error for ReasonForVisitCommand\ncoding.system\n Field required [type=missing",
|
|
142
|
-
{"
|
|
146
|
+
{"note_uuid": "00000000-0000-0000-0000-000000000000", "user_id": 1},
|
|
143
147
|
),
|
|
144
148
|
(
|
|
145
149
|
ReasonForVisitCommand,
|
|
146
|
-
{
|
|
150
|
+
{
|
|
151
|
+
"note_uuid": "00000000-0000-0000-0000-000000000000",
|
|
152
|
+
"user_id": 1,
|
|
153
|
+
"coding": {"code": 1, "system": "y"},
|
|
154
|
+
},
|
|
147
155
|
"1 validation error for ReasonForVisitCommand\ncoding.code\n Input should be a valid string [type=string_type",
|
|
148
|
-
{"
|
|
156
|
+
{"note_uuid": "00000000-0000-0000-0000-000000000000", "user_id": 1},
|
|
149
157
|
),
|
|
150
158
|
(
|
|
151
159
|
ReasonForVisitCommand,
|
|
152
|
-
{
|
|
160
|
+
{
|
|
161
|
+
"note_uuid": "00000000-0000-0000-0000-000000000000",
|
|
162
|
+
"user_id": 1,
|
|
163
|
+
"coding": {"code": None, "system": "y"},
|
|
164
|
+
},
|
|
153
165
|
"1 validation error for ReasonForVisitCommand\ncoding.code\n Input should be a valid string [type=string_type",
|
|
154
|
-
{"
|
|
166
|
+
{"note_uuid": "00000000-0000-0000-0000-000000000000", "user_id": 1},
|
|
155
167
|
),
|
|
156
168
|
(
|
|
157
169
|
ReasonForVisitCommand,
|
|
158
|
-
{
|
|
170
|
+
{
|
|
171
|
+
"note_uuid": "00000000-0000-0000-0000-000000000000",
|
|
172
|
+
"user_id": 1,
|
|
173
|
+
"coding": {"system": "y"},
|
|
174
|
+
},
|
|
159
175
|
"1 validation error for ReasonForVisitCommand\ncoding.code\n Field required [type=missing",
|
|
160
|
-
{"
|
|
176
|
+
{"note_uuid": "00000000-0000-0000-0000-000000000000", "user_id": 1},
|
|
161
177
|
),
|
|
162
178
|
(
|
|
163
179
|
ReasonForVisitCommand,
|
|
164
|
-
{
|
|
180
|
+
{
|
|
181
|
+
"note_uuid": "00000000-0000-0000-0000-000000000000",
|
|
182
|
+
"user_id": 1,
|
|
183
|
+
"coding": {"code": "x", "system": 1},
|
|
184
|
+
},
|
|
165
185
|
"1 validation error for ReasonForVisitCommand\ncoding.system\n Input should be a valid string [type=string_type",
|
|
166
|
-
{"
|
|
186
|
+
{"note_uuid": "00000000-0000-0000-0000-000000000000", "user_id": 1},
|
|
167
187
|
),
|
|
168
188
|
(
|
|
169
189
|
ReasonForVisitCommand,
|
|
170
|
-
{
|
|
190
|
+
{
|
|
191
|
+
"note_uuid": "00000000-0000-0000-0000-000000000000",
|
|
192
|
+
"user_id": 1,
|
|
193
|
+
"coding": {"code": "x", "system": None},
|
|
194
|
+
},
|
|
171
195
|
"1 validation error for ReasonForVisitCommand\ncoding.system\n Input should be a valid string [type=string_type",
|
|
172
|
-
{"
|
|
196
|
+
{"note_uuid": "00000000-0000-0000-0000-000000000000", "user_id": 1},
|
|
173
197
|
),
|
|
174
198
|
(
|
|
175
199
|
ReasonForVisitCommand,
|
|
176
|
-
{
|
|
200
|
+
{
|
|
201
|
+
"note_uuid": "00000000-0000-0000-0000-000000000000",
|
|
202
|
+
"user_id": 1,
|
|
203
|
+
"coding": {"code": "x", "system": "y", "display": 1},
|
|
204
|
+
},
|
|
177
205
|
"1 validation error for ReasonForVisitCommand\ncoding.display\n Input should be a valid string [type=string_type",
|
|
178
|
-
{"
|
|
206
|
+
{"note_uuid": "00000000-0000-0000-0000-000000000000", "user_id": 1},
|
|
179
207
|
),
|
|
180
208
|
],
|
|
181
209
|
)
|
|
@@ -186,7 +214,9 @@ def test_command_raises_specific_error_when_kwarg_given_incorrect_type(
|
|
|
186
214
|
valid_kwargs: dict,
|
|
187
215
|
) -> None:
|
|
188
216
|
with pytest.raises(ValidationError) as e1:
|
|
189
|
-
Command(**err_kwargs)
|
|
217
|
+
cmd = Command(**err_kwargs)
|
|
218
|
+
cmd.originate()
|
|
219
|
+
cmd.edit()
|
|
190
220
|
assert err_msg in repr(e1.value)
|
|
191
221
|
|
|
192
222
|
cmd = Command(**valid_kwargs)
|
|
@@ -195,6 +225,8 @@ def test_command_raises_specific_error_when_kwarg_given_incorrect_type(
|
|
|
195
225
|
key, value = list(err_kwargs.items())[-1]
|
|
196
226
|
with pytest.raises(ValidationError) as e2:
|
|
197
227
|
setattr(cmd, key, value)
|
|
228
|
+
cmd.originate()
|
|
229
|
+
cmd.edit()
|
|
198
230
|
assert err_msg in repr(e2.value)
|
|
199
231
|
|
|
200
232
|
|
|
@@ -219,7 +251,7 @@ def test_command_raises_specific_error_when_kwarg_given_incorrect_type(
|
|
|
219
251
|
),
|
|
220
252
|
(HistoryOfPresentIllnessCommand, ("narrative",)),
|
|
221
253
|
(MedicationStatementCommand, ("fdb_code", "sig")),
|
|
222
|
-
(PlanCommand, ("narrative", "user_id", "command_uuid", "
|
|
254
|
+
(PlanCommand, ("narrative", "user_id", "command_uuid", "note_uuid")),
|
|
223
255
|
(
|
|
224
256
|
PrescribeCommand,
|
|
225
257
|
(
|
|
@@ -268,15 +300,12 @@ def test_command_allows_kwarg_with_correct_type(
|
|
|
268
300
|
fields_to_test: tuple[str],
|
|
269
301
|
) -> None:
|
|
270
302
|
schema = Command.model_json_schema()
|
|
271
|
-
schema["required"].append("note_id")
|
|
272
|
-
required_fields = {k: v for k, v in schema["properties"].items() if k in schema["required"]}
|
|
273
|
-
base = {field: fake(props, Command) for field, props in required_fields.items()}
|
|
274
303
|
|
|
275
304
|
for field in fields_to_test:
|
|
276
|
-
field_type = get_field_type(
|
|
305
|
+
field_type = get_field_type(schema["properties"][field])
|
|
277
306
|
|
|
278
307
|
init_field_value = fake({"type": field_type}, Command)
|
|
279
|
-
init_kwargs =
|
|
308
|
+
init_kwargs = {field: init_field_value}
|
|
280
309
|
cmd = Command(**init_kwargs)
|
|
281
310
|
assert getattr(cmd, field) == init_field_value
|
|
282
311
|
|
|
@@ -284,24 +313,37 @@ def test_command_allows_kwarg_with_correct_type(
|
|
|
284
313
|
setattr(cmd, field, updated_field_value)
|
|
285
314
|
assert getattr(cmd, field) == updated_field_value
|
|
286
315
|
|
|
316
|
+
for method in ["originate", "edit", "delete", "commit", "enter_in_error"]:
|
|
317
|
+
required_fields = {
|
|
318
|
+
k: v
|
|
319
|
+
for k, v in schema["properties"].items()
|
|
320
|
+
if k in Command()._get_effect_method_required_fields(method)
|
|
321
|
+
}
|
|
322
|
+
base = {field: fake(props, Command) for field, props in required_fields.items()}
|
|
323
|
+
cmd = Command(**base)
|
|
324
|
+
effect = getattr(cmd, method)()
|
|
325
|
+
assert effect is not None
|
|
326
|
+
|
|
287
327
|
|
|
288
328
|
@pytest.fixture(scope="session")
|
|
289
|
-
def token() ->
|
|
290
|
-
return
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
+
)
|
|
299
341
|
|
|
300
342
|
|
|
301
343
|
@pytest.fixture
|
|
302
|
-
def
|
|
344
|
+
def note_uuid(token: MaskedValue) -> str:
|
|
303
345
|
headers = {
|
|
304
|
-
"Authorization": f"Bearer {token}",
|
|
346
|
+
"Authorization": f"Bearer {token.value}",
|
|
305
347
|
"Content-Type": "application/json",
|
|
306
348
|
"Accept": "application/json",
|
|
307
349
|
}
|
|
@@ -326,6 +368,9 @@ def command_type_map() -> dict[str, type]:
|
|
|
326
368
|
"TextField": str,
|
|
327
369
|
"ChoiceField": str,
|
|
328
370
|
"DateField": datetime,
|
|
371
|
+
"ApproximateDateField": datetime,
|
|
372
|
+
"IntegerField": int,
|
|
373
|
+
"DecimalField": decimal.Decimal,
|
|
329
374
|
}
|
|
330
375
|
|
|
331
376
|
|
|
@@ -334,14 +379,12 @@ def command_type_map() -> dict[str, type]:
|
|
|
334
379
|
"Command",
|
|
335
380
|
[
|
|
336
381
|
(AssessCommand),
|
|
337
|
-
|
|
338
|
-
# (DiagnoseCommand),
|
|
382
|
+
(DiagnoseCommand),
|
|
339
383
|
(GoalCommand),
|
|
340
384
|
(HistoryOfPresentIllnessCommand),
|
|
341
385
|
(MedicationStatementCommand),
|
|
342
386
|
(PlanCommand),
|
|
343
|
-
|
|
344
|
-
# (PrescribeCommand),
|
|
387
|
+
(PrescribeCommand),
|
|
345
388
|
(QuestionnaireCommand),
|
|
346
389
|
(ReasonForVisitCommand),
|
|
347
390
|
(StopMedicationCommand),
|
|
@@ -349,9 +392,9 @@ def command_type_map() -> dict[str, type]:
|
|
|
349
392
|
],
|
|
350
393
|
)
|
|
351
394
|
def test_command_schema_matches_command_api(
|
|
352
|
-
token:
|
|
395
|
+
token: MaskedValue,
|
|
353
396
|
command_type_map: dict[str, str],
|
|
354
|
-
|
|
397
|
+
note_uuid: str,
|
|
355
398
|
Command: (
|
|
356
399
|
AssessCommand
|
|
357
400
|
| DiagnoseCommand
|
|
@@ -367,8 +410,8 @@ def test_command_schema_matches_command_api(
|
|
|
367
410
|
),
|
|
368
411
|
) -> None:
|
|
369
412
|
# first create the command in the new note
|
|
370
|
-
data = {"noteKey":
|
|
371
|
-
headers = {"Authorization": f"Bearer {token}"}
|
|
413
|
+
data = {"noteKey": note_uuid, "schemaKey": Command.Meta.key}
|
|
414
|
+
headers = {"Authorization": f"Bearer {token.value}"}
|
|
372
415
|
url = f"{settings.INTEGRATION_TEST_URL}/core/api/v1/commands/"
|
|
373
416
|
command_resp = requests.post(url, headers=headers, data=data).json()
|
|
374
417
|
assert "uuid" in command_resp
|
|
@@ -396,7 +439,15 @@ def test_command_schema_matches_command_api(
|
|
|
396
439
|
expected_type = expected_field["type"]
|
|
397
440
|
if expected_type is Coding:
|
|
398
441
|
expected_type = expected_type.__annotations__["code"]
|
|
399
|
-
|
|
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
|
|
400
451
|
|
|
401
452
|
if (choices := actual_field["choices"]) is None:
|
|
402
453
|
assert expected_field["choices"] is None
|
canvas_sdk/data/__init__.py
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .base import DataModel
|
canvas_sdk/data/base.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from pydantic_core import ValidationError
|
|
2
|
+
|
|
3
|
+
from canvas_sdk.base import Model
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DataModel(Model):
|
|
7
|
+
class Meta:
|
|
8
|
+
update_required_fields = ("id",)
|
|
9
|
+
|
|
10
|
+
def model_dump_json_nested(self, *args, **kwargs) -> str:
|
|
11
|
+
"""
|
|
12
|
+
Returns the model's json representation nested in a {"data": {..}} key.
|
|
13
|
+
"""
|
|
14
|
+
return f'{{"data": {self.model_dump_json(*args, **kwargs)}}}'
|
|
15
|
+
|
|
16
|
+
def _validate_before_effect(self, method: str) -> None:
|
|
17
|
+
if method == "create" and getattr(self, "id", None):
|
|
18
|
+
error = self._create_error_detail(
|
|
19
|
+
"value", "create cannot be called on a model with an id", "id"
|
|
20
|
+
)
|
|
21
|
+
raise ValidationError.from_exception_data(self.__class__.__name__, [error])
|
|
22
|
+
super()._validate_before_effect(method)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data Access Layer client.
|
|
3
|
+
|
|
4
|
+
This module is primarily responsible for executing calls to the gRPC service so that such details
|
|
5
|
+
are abstracted away from callers. The return values of the methods on the client class are protobufs
|
|
6
|
+
which must be mapped to user-facing objects.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import functools
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from types import FunctionType
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import grpc
|
|
15
|
+
from grpc import StatusCode
|
|
16
|
+
|
|
17
|
+
from canvas_generated.data_access_layer.data_access_layer_pb2 import ID, Patient
|
|
18
|
+
from canvas_generated.data_access_layer.data_access_layer_pb2_grpc import (
|
|
19
|
+
DataAccessLayerStub,
|
|
20
|
+
)
|
|
21
|
+
from settings import DAL_TARGET
|
|
22
|
+
|
|
23
|
+
from . import exceptions
|
|
24
|
+
from .exceptions import DataModuleError
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _DataAccessLayerClientMeta(type):
|
|
28
|
+
"""
|
|
29
|
+
Metaclass for the Data Access Layer client class.
|
|
30
|
+
|
|
31
|
+
Wraps all methods of a class with a gRPC error handler.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __new__(cls, name: str, bases: tuple, attrs: dict) -> type:
|
|
35
|
+
for attr_name, attr_value in attrs.items():
|
|
36
|
+
if isinstance(attr_value, FunctionType):
|
|
37
|
+
attrs[attr_name] = cls.handle_grpc_errors(attr_value)
|
|
38
|
+
return super().__new__(cls, name, bases, attrs)
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def handle_grpc_errors(cls, func: Callable[..., Any]) -> Callable[..., Any]:
|
|
42
|
+
"""
|
|
43
|
+
Decorator that wraps a try-except block around all class methods. gRPC errors are mapped to
|
|
44
|
+
a defined set of exceptions from a Data Access Layer exception hierarchy.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
@functools.wraps(func)
|
|
48
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
49
|
+
try:
|
|
50
|
+
return func(*args, **kwargs)
|
|
51
|
+
except grpc.RpcError as error:
|
|
52
|
+
# gRPC exceptions aren't tightly defined, so we'll try to get a status code and
|
|
53
|
+
# error details, and handle it if we can't
|
|
54
|
+
try:
|
|
55
|
+
status_code = error.code()
|
|
56
|
+
except Exception:
|
|
57
|
+
status_code = None
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
error_details = error.details()
|
|
61
|
+
except Exception:
|
|
62
|
+
error_details = ""
|
|
63
|
+
|
|
64
|
+
# Map more gRPC status codes to exception types as needed
|
|
65
|
+
match status_code:
|
|
66
|
+
case StatusCode.NOT_FOUND:
|
|
67
|
+
raise exceptions.DataModuleNotFoundError(error_details) from error
|
|
68
|
+
case _:
|
|
69
|
+
raise exceptions.DataModuleError from error
|
|
70
|
+
except Exception as exception:
|
|
71
|
+
raise DataModuleError from exception
|
|
72
|
+
|
|
73
|
+
return wrapper
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class _DataAccessLayerClient(metaclass=_DataAccessLayerClientMeta):
|
|
77
|
+
"""
|
|
78
|
+
Data Access Layer client.
|
|
79
|
+
|
|
80
|
+
Do not instantiate -- just import the global variable DAL_CLIENT.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(self) -> None:
|
|
84
|
+
self._channel = grpc.insecure_channel(DAL_TARGET)
|
|
85
|
+
self._stub = DataAccessLayerStub(self._channel)
|
|
86
|
+
|
|
87
|
+
def get_patient(self, id: str) -> Patient:
|
|
88
|
+
"""Given an ID, get the Patient from the Data Access Layer."""
|
|
89
|
+
return self._stub.GetPatient(ID(id=id))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# There should only be one instantiation of the client, so this global will act as a singleton in a
|
|
93
|
+
# way. This is the value that should be imported; no one should be instantiating the DAL client
|
|
94
|
+
# (hence the underscore notation indicating that the class is "private").
|
|
95
|
+
DAL_CLIENT = _DataAccessLayerClient()
|