canvas 0.1.12b0__py3-none-any.whl → 0.1.14__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 (60) hide show
  1. {canvas-0.1.12b0.dist-info → canvas-0.1.14.dist-info}/METADATA +43 -2
  2. canvas-0.1.14.dist-info/RECORD +90 -0
  3. {canvas-0.1.12b0.dist-info → canvas-0.1.14.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/messages/effects_pb2.py +28 -0
  9. canvas_generated/messages/effects_pb2.pyi +147 -0
  10. canvas_generated/messages/events_pb2.py +31 -0
  11. {generated → canvas_generated}/messages/events_pb2.pyi +3 -1
  12. {generated → canvas_generated}/messages/plugins_pb2.py +7 -7
  13. canvas_generated/services/plugin_runner_pb2.py +28 -0
  14. {generated → canvas_generated}/services/plugin_runner_pb2.pyi +2 -2
  15. {generated → canvas_generated}/services/plugin_runner_pb2_grpc.py +14 -14
  16. canvas_sdk/base.py +45 -0
  17. canvas_sdk/commands/base.py +61 -41
  18. canvas_sdk/commands/commands/assess.py +6 -2
  19. canvas_sdk/commands/commands/diagnose.py +4 -14
  20. canvas_sdk/commands/commands/goal.py +3 -2
  21. canvas_sdk/commands/commands/history_present_illness.py +2 -1
  22. canvas_sdk/commands/commands/medication_statement.py +6 -2
  23. canvas_sdk/commands/commands/plan.py +2 -1
  24. canvas_sdk/commands/commands/prescribe.py +24 -11
  25. canvas_sdk/commands/commands/questionnaire.py +6 -2
  26. canvas_sdk/commands/commands/reason_for_visit.py +13 -6
  27. canvas_sdk/commands/commands/stop_medication.py +6 -2
  28. canvas_sdk/commands/commands/update_goal.py +4 -1
  29. canvas_sdk/commands/tests/test_utils.py +31 -64
  30. canvas_sdk/commands/tests/tests.py +116 -65
  31. canvas_sdk/data/__init__.py +1 -0
  32. canvas_sdk/data/base.py +22 -0
  33. canvas_sdk/data/patient.py +6 -0
  34. canvas_sdk/data/staff.py +6 -0
  35. canvas_sdk/data/task.py +60 -0
  36. canvas_sdk/effects/__init__.py +1 -1
  37. canvas_sdk/effects/banner_alert/__init__.py +2 -0
  38. canvas_sdk/effects/banner_alert/add_banner_alert.py +49 -0
  39. canvas_sdk/effects/banner_alert/remove_banner_alert.py +20 -0
  40. canvas_sdk/effects/base.py +4 -6
  41. canvas_sdk/events/__init__.py +1 -1
  42. canvas_sdk/handlers/__init__.py +1 -0
  43. canvas_sdk/handlers/base.py +16 -0
  44. canvas_sdk/handlers/cron_task.py +35 -0
  45. canvas_sdk/protocols/base.py +2 -11
  46. canvas_sdk/utils/stats.py +27 -0
  47. logger/__init__.py +2 -0
  48. logger/logger.py +48 -0
  49. canvas-0.1.12b0.dist-info/RECORD +0 -78
  50. canvas_sdk/effects/banner_alert/banner_alert.py +0 -37
  51. canvas_sdk/effects/banner_alert/constants.py +0 -19
  52. generated/messages/effects_pb2.py +0 -28
  53. generated/messages/effects_pb2.pyi +0 -25
  54. generated/messages/events_pb2.py +0 -31
  55. generated/services/plugin_runner_pb2.py +0 -28
  56. {canvas-0.1.12b0.dist-info → canvas-0.1.14.dist-info}/entry_points.txt +0 -0
  57. {generated → canvas_generated}/messages/effects_pb2_grpc.py +0 -0
  58. {generated → canvas_generated}/messages/events_pb2_grpc.py +0 -0
  59. {generated → canvas_generated}/messages/plugins_pb2.pyi +0 -0
  60. {generated → canvas_generated}/messages/plugins_pb2_grpc.py +0 -0
@@ -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,6 @@
1
+ from canvas_sdk.data import DataModel
2
+
3
+
4
+ class Patient(DataModel):
5
+ id: str | None = None
6
+ # TODO - populate more attributes
@@ -0,0 +1,6 @@
1
+ from canvas_sdk.data import DataModel
2
+
3
+
4
+ class Staff(DataModel):
5
+ id: str | None = None
6
+ # TODO - populate with first name, last name, additional attributes, etc.
@@ -0,0 +1,60 @@
1
+ from datetime import datetime
2
+ from enum import Enum
3
+
4
+ from canvas_sdk.data import DataModel
5
+ from canvas_sdk.data.patient import Patient
6
+ from canvas_sdk.data.staff import Staff
7
+ from canvas_sdk.effects import Effect, EffectType
8
+
9
+
10
+ class Task(DataModel):
11
+ class Meta(DataModel.Meta):
12
+ create_required_fields = ("title",)
13
+
14
+ class Status(Enum):
15
+ COMPLETED = "Completed"
16
+ CLOSED = "Closed"
17
+ OPEN = "Open"
18
+
19
+ id: str | None = None
20
+ assignee: Staff | None = None
21
+ patient: Patient | None = None
22
+ title: str | None = None
23
+ due: datetime | None = None
24
+ status: Status | None = None
25
+ comments: "list[TaskComment] | None" = None
26
+ labels: list[str] | None = None
27
+
28
+ def create(self) -> Effect:
29
+ self._validate_before_effect("create")
30
+ return Effect(type=EffectType.CREATE_TASK, payload=self.model_dump_json_nested())
31
+
32
+ def update(self) -> Effect:
33
+ self._validate_before_effect("update")
34
+ payload = self.model_dump_json_nested(exclude_unset=True)
35
+ return Effect(type=EffectType.UPDATE_TASK, payload=payload)
36
+
37
+ def add_comment(self, comment: str) -> Effect:
38
+ if not self.id:
39
+ raise ValueError("Cannot add a comment to a Task without an id")
40
+ task_comment = TaskComment(task=self, body=comment)
41
+ task_comment._validate_before_effect("create")
42
+ return Effect(
43
+ type=EffectType.CREATE_TASK_COMMENT,
44
+ payload=task_comment.model_dump_json_nested(exclude_unset=True),
45
+ )
46
+
47
+
48
+ class TaskComment(DataModel):
49
+ class Meta:
50
+ create_required_fields = (
51
+ "body",
52
+ "task",
53
+ )
54
+
55
+ id: str | None = None
56
+ task: Task | None = None
57
+ body: str | None = None
58
+
59
+
60
+ Task.model_rebuild()
@@ -1 +1 @@
1
- from generated.messages.effects_pb2 import Effect, EffectType
1
+ from canvas_generated.messages.effects_pb2 import Effect, EffectType
@@ -0,0 +1,2 @@
1
+ from canvas_sdk.effects.banner_alert.add_banner_alert import AddBannerAlert
2
+ from canvas_sdk.effects.banner_alert.remove_banner_alert import RemoveBannerAlert
@@ -0,0 +1,49 @@
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 AddBannerAlert(_BaseEffect):
10
+ """
11
+ An Effect that will result in a banner alert in Canvas.
12
+ """
13
+
14
+ class Meta:
15
+ effect_type = EffectType.ADD_BANNER_ALERT
16
+
17
+ class Placement(Enum):
18
+ CHART = "chart"
19
+ TIMELINE = "timeline"
20
+ APPOINTMENT_CARD = "appointment_card"
21
+ SCHEDULING_CARD = "scheduling_card"
22
+ PROFILE = "profile"
23
+
24
+ class Intent(Enum):
25
+ INFO = "info"
26
+ WARNING = "warning"
27
+ ALERT = "alert"
28
+
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
34
+ href: str | None = None
35
+
36
+ @property
37
+ def values(self) -> dict[str, Any]:
38
+ """The BannerAlert's values."""
39
+ return {
40
+ "narrative": self.narrative,
41
+ "placement": [p.value for p in self.placement],
42
+ "intent": self.intent.value,
43
+ "href": self.href,
44
+ }
45
+
46
+ @property
47
+ def effect_payload(self) -> dict[str, Any]:
48
+ """The payload of the effect."""
49
+ return {"patient": self.patient_id, "key": self.key, "data": self.values}
@@ -0,0 +1,20 @@
1
+ from typing import Any
2
+
3
+ from canvas_sdk.effects.base import EffectType, _BaseEffect
4
+
5
+
6
+ class RemoveBannerAlert(_BaseEffect):
7
+ """
8
+ An Effect that will remove/inactivate a banner alert in Canvas.
9
+ """
10
+
11
+ class Meta:
12
+ effect_type = EffectType.REMOVE_BANNER_ALERT
13
+
14
+ patient_id: str
15
+ key: str
16
+
17
+ @property
18
+ def effect_payload(self) -> dict[str, Any]:
19
+ """The payload of the effect."""
20
+ return {"patient": self.patient_id, "key": self.key}