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.

Files changed (65) hide show
  1. {canvas-0.1.12b0.dist-info → canvas-0.1.15.dist-info}/METADATA +43 -2
  2. canvas-0.1.15.dist-info/RECORD +95 -0
  3. {canvas-0.1.12b0.dist-info → canvas-0.1.15.dist-info}/WHEEL +1 -1
  4. canvas_cli/apps/plugin/__init__.py +3 -1
  5. canvas_cli/apps/plugin/plugin.py +74 -0
  6. canvas_cli/main.py +5 -3
  7. canvas_cli/templates/plugins/default/{{ cookiecutter.__project_slug }}/protocols/my_protocol.py +3 -0
  8. canvas_generated/data_access_layer/data_access_layer_pb2.py +30 -0
  9. canvas_generated/data_access_layer/data_access_layer_pb2.pyi +23 -0
  10. canvas_generated/data_access_layer/data_access_layer_pb2_grpc.py +66 -0
  11. canvas_generated/messages/effects_pb2.py +28 -0
  12. canvas_generated/messages/effects_pb2.pyi +147 -0
  13. canvas_generated/messages/events_pb2.py +31 -0
  14. {generated → canvas_generated}/messages/events_pb2.pyi +3 -1
  15. {generated → canvas_generated}/messages/plugins_pb2.py +7 -7
  16. canvas_generated/services/plugin_runner_pb2.py +28 -0
  17. {generated → canvas_generated}/services/plugin_runner_pb2.pyi +2 -2
  18. {generated → canvas_generated}/services/plugin_runner_pb2_grpc.py +14 -14
  19. canvas_sdk/base.py +45 -0
  20. canvas_sdk/commands/base.py +61 -41
  21. canvas_sdk/commands/commands/assess.py +6 -2
  22. canvas_sdk/commands/commands/diagnose.py +4 -14
  23. canvas_sdk/commands/commands/goal.py +3 -2
  24. canvas_sdk/commands/commands/history_present_illness.py +2 -1
  25. canvas_sdk/commands/commands/medication_statement.py +6 -2
  26. canvas_sdk/commands/commands/plan.py +2 -1
  27. canvas_sdk/commands/commands/prescribe.py +24 -11
  28. canvas_sdk/commands/commands/questionnaire.py +6 -2
  29. canvas_sdk/commands/commands/reason_for_visit.py +13 -6
  30. canvas_sdk/commands/commands/stop_medication.py +6 -2
  31. canvas_sdk/commands/commands/update_goal.py +4 -1
  32. canvas_sdk/commands/tests/test_utils.py +31 -64
  33. canvas_sdk/commands/tests/tests.py +116 -65
  34. canvas_sdk/data/__init__.py +1 -0
  35. canvas_sdk/data/base.py +22 -0
  36. canvas_sdk/data/data_access_layer_client.py +95 -0
  37. canvas_sdk/data/exceptions.py +16 -0
  38. canvas_sdk/data/patient.py +26 -0
  39. canvas_sdk/data/staff.py +6 -0
  40. canvas_sdk/data/task.py +60 -0
  41. canvas_sdk/effects/__init__.py +1 -1
  42. canvas_sdk/effects/banner_alert/__init__.py +2 -0
  43. canvas_sdk/effects/banner_alert/add_banner_alert.py +49 -0
  44. canvas_sdk/effects/banner_alert/remove_banner_alert.py +20 -0
  45. canvas_sdk/effects/base.py +4 -6
  46. canvas_sdk/events/__init__.py +1 -1
  47. canvas_sdk/handlers/__init__.py +1 -0
  48. canvas_sdk/handlers/base.py +16 -0
  49. canvas_sdk/handlers/cron_task.py +35 -0
  50. canvas_sdk/protocols/base.py +2 -11
  51. canvas_sdk/utils/stats.py +27 -0
  52. logger/__init__.py +2 -0
  53. logger/logger.py +48 -0
  54. canvas-0.1.12b0.dist-info/RECORD +0 -78
  55. canvas_sdk/effects/banner_alert/banner_alert.py +0 -37
  56. canvas_sdk/effects/banner_alert/constants.py +0 -19
  57. generated/messages/effects_pb2.py +0 -28
  58. generated/messages/effects_pb2.pyi +0 -25
  59. generated/messages/events_pb2.py +0 -31
  60. generated/services/plugin_runner_pb2.py +0 -28
  61. {canvas-0.1.12b0.dist-info → canvas-0.1.15.dist-info}/entry_points.txt +0 -0
  62. {generated → canvas_generated}/messages/effects_pb2_grpc.py +0 -0
  63. {generated → canvas_generated}/messages/events_pb2_grpc.py +0 -0
  64. {generated → canvas_generated}/messages/plugins_pb2.pyi +0 -0
  65. {generated → canvas_generated}/messages/plugins_pb2_grpc.py +0 -0
@@ -1,5 +1,6 @@
1
- from pydantic import model_validator
2
- from typing_extensions import Self
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
- @model_validator(mode="after")
20
- def _verify_structured_has_a_coding(self) -> Self:
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
- raise ValueError("Structured RFV should have a coding.")
23
- return self
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(json_schema_extra={"commands_api_name": "medication"})
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(json_schema_extra={"commands_api_name": "goal_statement"})
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 raises_missing_error(
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 = base | {field: None}
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 = base | {field: fake(field_props, Command)}
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, None)
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 raises_wrong_type_error(
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
- field: str,
150
+ method: str,
166
151
  ) -> None:
167
- field_props = Command.model_json_schema()["properties"][field]
168
- field_type = get_field_type(field_props)
169
- wrong_field_type = "integer" if field_type == "string" else "string"
170
-
171
- with pytest.raises(ValidationError) as e1:
172
- err_kwargs = base | {field: fake({"type": wrong_field_type}, Command)}
173
- Command(**err_kwargs)
174
- err_msg1 = repr(e1.value)
175
-
176
- valid_kwargs = base | {field: fake(field_props, Command)}
177
- cmd = Command(**valid_kwargs)
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
- raises_missing_error,
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(base, Command, field)
106
- if field in required_fields:
107
- raises_missing_error(base, Command, field)
108
- raises_none_error(base, Command, field)
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 Value error, Command should have either a note_id or a command_uuid. [type=value",
118
- {"narrative": "yo", "note_id": 1, "user_id": 1},
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": 1, "note_id": None},
123
- "1 validation error for PlanCommand\n Value error, Command should have either a note_id or a command_uuid. [type=value",
124
- {"narrative": "yo", "note_id": 1, "user_id": 1},
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", "user_id": 5, "note_id": "100"},
129
- "1 validation error for PlanCommand\nnote_id\n Input should be a valid integer [type=int_type",
130
- {"narrative": "yo", "note_id": 1, "user_id": 1},
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
- {"note_id": 1, "user_id": 1, "structured": True},
135
- "1 validation error for ReasonForVisitCommand\n Value error, Structured RFV should have a coding.",
136
- {"note_id": 1, "user_id": 1, "structured": False},
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
- {"note_id": 1, "user_id": 1, "coding": {"code": "x"}},
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
- {"note_id": 1, "user_id": 1},
146
+ {"note_uuid": "00000000-0000-0000-0000-000000000000", "user_id": 1},
143
147
  ),
144
148
  (
145
149
  ReasonForVisitCommand,
146
- {"note_id": 1, "user_id": 1, "coding": {"code": 1, "system": "y"}},
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
- {"note_id": 1, "user_id": 1},
156
+ {"note_uuid": "00000000-0000-0000-0000-000000000000", "user_id": 1},
149
157
  ),
150
158
  (
151
159
  ReasonForVisitCommand,
152
- {"note_id": 1, "user_id": 1, "coding": {"code": None, "system": "y"}},
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
- {"note_id": 1, "user_id": 1},
166
+ {"note_uuid": "00000000-0000-0000-0000-000000000000", "user_id": 1},
155
167
  ),
156
168
  (
157
169
  ReasonForVisitCommand,
158
- {"note_id": 1, "user_id": 1, "coding": {"system": "y"}},
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
- {"note_id": 1, "user_id": 1},
176
+ {"note_uuid": "00000000-0000-0000-0000-000000000000", "user_id": 1},
161
177
  ),
162
178
  (
163
179
  ReasonForVisitCommand,
164
- {"note_id": 1, "user_id": 1, "coding": {"code": "x", "system": 1}},
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
- {"note_id": 1, "user_id": 1},
186
+ {"note_uuid": "00000000-0000-0000-0000-000000000000", "user_id": 1},
167
187
  ),
168
188
  (
169
189
  ReasonForVisitCommand,
170
- {"note_id": 1, "user_id": 1, "coding": {"code": "x", "system": None}},
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
- {"note_id": 1, "user_id": 1},
196
+ {"note_uuid": "00000000-0000-0000-0000-000000000000", "user_id": 1},
173
197
  ),
174
198
  (
175
199
  ReasonForVisitCommand,
176
- {"note_id": 1, "user_id": 1, "coding": {"code": "x", "system": "y", "display": 1}},
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
- {"note_id": 1, "user_id": 1},
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", "note_id")),
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(Command.model_json_schema()["properties"][field])
305
+ field_type = get_field_type(schema["properties"][field])
277
306
 
278
307
  init_field_value = fake({"type": field_type}, Command)
279
- init_kwargs = base | {field: init_field_value}
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() -> str:
290
- return requests.post(
291
- f"{settings.INTEGRATION_TEST_URL}/auth/token/",
292
- headers={"Content-Type": "application/x-www-form-urlencoded"},
293
- data={
294
- "grant_type": "client_credentials",
295
- "client_id": settings.INTEGRATION_TEST_CLIENT_ID,
296
- "client_secret": settings.INTEGRATION_TEST_CLIENT_SECRET,
297
- },
298
- ).json()["access_token"]
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 note_id(token: str) -> str:
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
- # todo: add Diagnose once it has an adapter in home-app
338
- # (DiagnoseCommand),
382
+ (DiagnoseCommand),
339
383
  (GoalCommand),
340
384
  (HistoryOfPresentIllnessCommand),
341
385
  (MedicationStatementCommand),
342
386
  (PlanCommand),
343
- # todo: add Prescribe once its been refactored
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: str,
395
+ token: MaskedValue,
353
396
  command_type_map: dict[str, str],
354
- note_id: str,
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": note_id, "schemaKey": Command.Meta.key}
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
- assert expected_type == command_type_map.get(actual_field["type"])
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
@@ -0,0 +1 @@
1
+ from .base import DataModel
@@ -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()