canvas 0.1.12__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 (56) hide show
  1. {canvas-0.1.12.dist-info → canvas-0.1.14.dist-info}/METADATA +42 -1
  2. canvas-0.1.14.dist-info/RECORD +90 -0
  3. canvas_cli/apps/plugin/__init__.py +3 -1
  4. canvas_cli/apps/plugin/plugin.py +74 -0
  5. canvas_cli/main.py +5 -3
  6. canvas_cli/templates/plugins/default/{{ cookiecutter.__project_slug }}/protocols/my_protocol.py +3 -0
  7. canvas_generated/messages/effects_pb2.py +28 -0
  8. canvas_generated/messages/effects_pb2.pyi +147 -0
  9. canvas_generated/messages/effects_pb2_grpc.py +4 -0
  10. canvas_generated/messages/events_pb2.py +31 -0
  11. canvas_generated/messages/events_pb2.pyi +445 -0
  12. canvas_generated/messages/events_pb2_grpc.py +4 -0
  13. canvas_generated/messages/plugins_pb2.py +28 -0
  14. canvas_generated/messages/plugins_pb2.pyi +15 -0
  15. canvas_generated/messages/plugins_pb2_grpc.py +4 -0
  16. canvas_generated/services/plugin_runner_pb2.py +28 -0
  17. canvas_generated/services/plugin_runner_pb2.pyi +6 -0
  18. canvas_generated/services/plugin_runner_pb2_grpc.py +100 -0
  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/patient.py +6 -0
  37. canvas_sdk/data/staff.py +6 -0
  38. canvas_sdk/data/task.py +60 -0
  39. canvas_sdk/effects/__init__.py +1 -1
  40. canvas_sdk/effects/banner_alert/__init__.py +2 -0
  41. canvas_sdk/effects/banner_alert/add_banner_alert.py +49 -0
  42. canvas_sdk/effects/banner_alert/remove_banner_alert.py +20 -0
  43. canvas_sdk/effects/base.py +4 -6
  44. canvas_sdk/events/__init__.py +1 -1
  45. canvas_sdk/handlers/__init__.py +1 -0
  46. canvas_sdk/handlers/base.py +16 -0
  47. canvas_sdk/handlers/cron_task.py +35 -0
  48. canvas_sdk/protocols/base.py +2 -11
  49. canvas_sdk/utils/stats.py +27 -0
  50. logger/__init__.py +2 -0
  51. logger/logger.py +48 -0
  52. canvas-0.1.12.dist-info/RECORD +0 -66
  53. canvas_sdk/effects/banner_alert/banner_alert.py +0 -37
  54. canvas_sdk/effects/banner_alert/constants.py +0 -19
  55. {canvas-0.1.12.dist-info → canvas-0.1.14.dist-info}/WHEEL +0 -0
  56. {canvas-0.1.12.dist-info → canvas-0.1.14.dist-info}/entry_points.txt +0 -0
@@ -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}
@@ -1,8 +1,9 @@
1
+ import json
1
2
  from typing import Any
2
3
 
3
4
  from pydantic import BaseModel, ConfigDict
4
5
 
5
- from canvas_sdk.effects import Effect
6
+ from canvas_sdk.effects import Effect, EffectType
6
7
 
7
8
 
8
9
  class _BaseEffect(BaseModel):
@@ -11,7 +12,7 @@ class _BaseEffect(BaseModel):
11
12
  """
12
13
 
13
14
  class Meta:
14
- effect_type = ""
15
+ effect_type = EffectType.UNKNOWN_EFFECT
15
16
 
16
17
  model_config = ConfigDict(strict=True, validate_assignment=True)
17
18
 
@@ -24,7 +25,4 @@ class _BaseEffect(BaseModel):
24
25
  return {"data": self.values}
25
26
 
26
27
  def apply(self) -> Effect:
27
- return {
28
- "type": self.Meta.effect_type,
29
- "payload": self.effect_payload,
30
- }
28
+ return Effect(type=self.Meta.effect_type, payload=json.dumps(self.effect_payload))
@@ -1 +1 @@
1
- from generated.messages.events_pb2 import Event, EventResponse, EventType
1
+ from canvas_generated.messages.events_pb2 import Event, EventResponse, EventType
@@ -0,0 +1 @@
1
+ from canvas_sdk.handlers.base import BaseHandler
@@ -0,0 +1,16 @@
1
+ import json
2
+
3
+
4
+ class BaseHandler:
5
+ """
6
+ The class that all handlers inherit from.
7
+ """
8
+
9
+ def __init__(self, event, secrets=None) -> None:
10
+ self.event = event
11
+ try:
12
+ self.context = json.loads(event.context)
13
+ except ValueError:
14
+ self.context = {}
15
+ self.target = event.target
16
+ self.secrets = secrets or {}
@@ -0,0 +1,35 @@
1
+ from abc import abstractmethod
2
+
3
+ import arrow
4
+ from cron_converter import Cron
5
+
6
+ from canvas_sdk.effects import Effect
7
+ from canvas_sdk.events import EventType
8
+ from canvas_sdk.handlers.base import BaseHandler
9
+
10
+
11
+ class CronTask(BaseHandler):
12
+ """
13
+ A type of handler that executes periodically according to a provided schedule.
14
+ """
15
+
16
+ RESPONDS_TO = EventType.Name(EventType.CRON)
17
+
18
+ SCHEDULE: str = "" # e.g. "* * * * *" for every minute.
19
+
20
+ @abstractmethod
21
+ def execute(self) -> list[Effect]:
22
+ """
23
+ Perform some work and return a list of effects.
24
+ """
25
+
26
+ def compute(self) -> list[Effect]:
27
+ """
28
+ See if the task should execute given the timestamp.
29
+ """
30
+ if not self.SCHEDULE:
31
+ raise ValueError("You must set a SCHEDULE.")
32
+ datetime = arrow.get(self.target).datetime
33
+ if datetime in Cron(self.SCHEDULE):
34
+ return self.execute()
35
+ return []
@@ -1,16 +1,7 @@
1
- import json
1
+ from canvas_sdk.handlers.base import BaseHandler
2
2
 
3
3
 
4
- class BaseProtocol:
4
+ class BaseProtocol(BaseHandler):
5
5
  """
6
6
  The class that protocols inherit from.
7
7
  """
8
-
9
- def __init__(self, event, secrets=None) -> None:
10
- self.event = event
11
- try:
12
- self.context = json.loads(event.context)
13
- except ValueError:
14
- self.context = {}
15
- self.target = event.target
16
- self.secrets = secrets or {}
@@ -0,0 +1,27 @@
1
+ from time import time
2
+ from typing import Any
3
+
4
+
5
+ def get_duration_ms(start_time: time) -> int:
6
+ return int((time() - start_time) * 1000)
7
+
8
+
9
+ LINE_PROTOCOL_TRANSLATION = str.maketrans(
10
+ {
11
+ ",": r"\,",
12
+ "=": r"\=",
13
+ " ": r"\ ",
14
+ ":": r"__",
15
+ }
16
+ )
17
+
18
+
19
+ def tags_to_line_protocol(tags: dict[str, Any]) -> str:
20
+ """Generate a tags string compatible with the InfluxDB line protocol.
21
+
22
+ See: https://docs.influxdata.com/influxdb/v1.1/write_protocols/line_protocol_tutorial/
23
+ """
24
+ return ",".join(
25
+ f"{tag_name}={str(tag_value).translate(LINE_PROTOCOL_TRANSLATION)}"
26
+ for tag_name, tag_value in tags.items()
27
+ )
logger/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ from logger.logger import PluginLogger
2
+ log = PluginLogger()