canvas 0.17.0__py3-none-any.whl → 0.19.0__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.17.0.dist-info → canvas-0.19.0.dist-info}/METADATA +1 -1
- {canvas-0.17.0.dist-info → canvas-0.19.0.dist-info}/RECORD +35 -21
- canvas_cli/apps/plugin/plugin.py +4 -0
- canvas_cli/utils/validators/manifest_schema.py +12 -0
- canvas_generated/messages/events_pb2.py +2 -2
- canvas_generated/messages/events_pb2.pyi +88 -0
- canvas_sdk/commands/commands/reason_for_visit.py +18 -3
- canvas_sdk/commands/tests/test_utils.py +15 -3
- canvas_sdk/commands/tests/unit/tests.py +7 -16
- canvas_sdk/effects/surescripts/__init__.py +11 -0
- canvas_sdk/effects/task/__init__.py +3 -0
- canvas_sdk/effects/task/task.py +3 -0
- canvas_sdk/questionnaires/__init__.py +3 -0
- canvas_sdk/questionnaires/tests/__init__.py +0 -0
- canvas_sdk/questionnaires/tests/test_utils.py +74 -0
- canvas_sdk/questionnaires/utils.py +117 -0
- canvas_sdk/templates/utils.py +7 -12
- canvas_sdk/utils/__init__.py +8 -2
- canvas_sdk/utils/http.py +112 -2
- canvas_sdk/utils/plugins.py +25 -0
- canvas_sdk/v1/data/__init__.py +4 -1
- canvas_sdk/v1/data/billing.py +29 -2
- canvas_sdk/v1/data/coverage.py +1 -1
- canvas_sdk/v1/data/reason_for_visit.py +22 -0
- canvas_sdk/v1/data/team.py +76 -0
- plugin_runner/sandbox.py +6 -6
- plugin_runner/tests/fixtures/plugins/test_load_questionnaire/CANVAS_MANIFEST.json +52 -0
- plugin_runner/tests/fixtures/plugins/test_load_questionnaire/README.md +11 -0
- plugin_runner/tests/fixtures/plugins/test_load_questionnaire/protocols/__init__.py +0 -0
- plugin_runner/tests/fixtures/plugins/test_load_questionnaire/protocols/my_protocol.py +39 -0
- plugin_runner/tests/fixtures/plugins/test_load_questionnaire/questionnaires/example_questionnaire.yml +61 -0
- plugin_runner/tests/test_plugin_runner.py +12 -12
- protobufs/canvas_generated/messages/events.proto +44 -36
- {canvas-0.17.0.dist-info → canvas-0.19.0.dist-info}/WHEEL +0 -0
- {canvas-0.17.0.dist-info → canvas-0.19.0.dist-info}/entry_points.txt +0 -0
|
@@ -37,6 +37,12 @@ from canvas_sdk.commands.constants import ClinicalQuantity, Coding
|
|
|
37
37
|
runner = CliRunner()
|
|
38
38
|
|
|
39
39
|
|
|
40
|
+
class WrongType:
|
|
41
|
+
"""A type to yield ValidationErrors in tests."""
|
|
42
|
+
|
|
43
|
+
wrong_field: str
|
|
44
|
+
|
|
45
|
+
|
|
40
46
|
class MaskedValue:
|
|
41
47
|
"""A class to mask sensitive values in tests."""
|
|
42
48
|
|
|
@@ -97,6 +103,8 @@ def fake(field_props: dict, Command: type[_BaseCommand]) -> Any:
|
|
|
97
103
|
return Coding(system=random_string(), code=random_string(), display=random_string())
|
|
98
104
|
case "ClinicalQuantity":
|
|
99
105
|
return ClinicalQuantity(representative_ndc="ndc", ncpdp_quantity_qualifier_code="code")
|
|
106
|
+
case "WrongType":
|
|
107
|
+
return WrongType()
|
|
100
108
|
if t[0].isupper():
|
|
101
109
|
return random.choice(list(getattr(Command, t)))
|
|
102
110
|
|
|
@@ -108,7 +116,8 @@ def raises_wrong_type_error(
|
|
|
108
116
|
"""Test that the correct error is raised when the wrong type is passed to a field."""
|
|
109
117
|
field_props = Command.model_json_schema()["properties"][field]
|
|
110
118
|
field_type = get_field_type(field_props)
|
|
111
|
-
|
|
119
|
+
|
|
120
|
+
wrong_field_type = "WrongType"
|
|
112
121
|
|
|
113
122
|
with pytest.raises(ValidationError) as e1:
|
|
114
123
|
err_kwargs = {field: fake({"type": wrong_field_type}, Command)}
|
|
@@ -122,8 +131,11 @@ def raises_wrong_type_error(
|
|
|
122
131
|
setattr(cmd, field, err_value)
|
|
123
132
|
err_msg2 = repr(e2.value)
|
|
124
133
|
|
|
125
|
-
assert
|
|
126
|
-
assert f"
|
|
134
|
+
assert "validation error" in err_msg1
|
|
135
|
+
assert f"{Command.__name__}\n{field}" in err_msg1
|
|
136
|
+
|
|
137
|
+
assert "validation error" in err_msg2
|
|
138
|
+
assert f"{Command.__name__}\n{field}" in err_msg1
|
|
127
139
|
|
|
128
140
|
field_type = (
|
|
129
141
|
"dictionary" if field_type == "Coding" or field_type == "ClinicalQuantity" else field_type
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
|
|
1
3
|
import pytest
|
|
2
4
|
from pydantic import ValidationError
|
|
3
5
|
from typer.testing import CliRunner
|
|
@@ -91,24 +93,21 @@ def test_command_raises_generic_error_when_kwarg_given_incorrect_type(
|
|
|
91
93
|
|
|
92
94
|
|
|
93
95
|
@pytest.mark.parametrize(
|
|
94
|
-
"Command,err_kwargs,
|
|
96
|
+
"Command,err_kwargs,valid_kwargs",
|
|
95
97
|
[
|
|
96
98
|
(
|
|
97
99
|
PlanCommand,
|
|
98
100
|
{"narrative": "yo", "note_uuid": 1},
|
|
99
|
-
"1 validation error for PlanCommand\nnote_uuid\n Input should be a valid string [type=string_type",
|
|
100
101
|
{"narrative": "yo", "note_uuid": "00000000-0000-0000-0000-000000000000"},
|
|
101
102
|
),
|
|
102
103
|
(
|
|
103
104
|
PlanCommand,
|
|
104
105
|
{"narrative": "yo", "note_uuid": "5", "command_uuid": 5},
|
|
105
|
-
"1 validation error for PlanCommand\ncommand_uuid\n Input should be a valid string [type=string_type",
|
|
106
106
|
{"narrative": "yo", "note_uuid": "5", "command_uuid": "5"},
|
|
107
107
|
),
|
|
108
108
|
(
|
|
109
109
|
ReasonForVisitCommand,
|
|
110
110
|
{"note_uuid": "00000000-0000-0000-0000-000000000000", "structured": True},
|
|
111
|
-
"1 validation error for ReasonForVisitCommand\n Structured RFV should have a coding",
|
|
112
111
|
{
|
|
113
112
|
"note_uuid": "00000000-0000-0000-0000-000000000000",
|
|
114
113
|
"structured": False,
|
|
@@ -120,7 +119,6 @@ def test_command_raises_generic_error_when_kwarg_given_incorrect_type(
|
|
|
120
119
|
"note_uuid": "00000000-0000-0000-0000-000000000000",
|
|
121
120
|
"coding": {"code": "x"},
|
|
122
121
|
},
|
|
123
|
-
"1 validation error for ReasonForVisitCommand\ncoding.system\n Field required [type=missing",
|
|
124
122
|
{"note_uuid": "00000000-0000-0000-0000-000000000000"},
|
|
125
123
|
),
|
|
126
124
|
(
|
|
@@ -129,7 +127,6 @@ def test_command_raises_generic_error_when_kwarg_given_incorrect_type(
|
|
|
129
127
|
"note_uuid": "00000000-0000-0000-0000-000000000000",
|
|
130
128
|
"coding": {"code": 1, "system": "y"},
|
|
131
129
|
},
|
|
132
|
-
"1 validation error for ReasonForVisitCommand\ncoding.code\n Input should be a valid string [type=string_type",
|
|
133
130
|
{"note_uuid": "00000000-0000-0000-0000-000000000000"},
|
|
134
131
|
),
|
|
135
132
|
(
|
|
@@ -138,7 +135,6 @@ def test_command_raises_generic_error_when_kwarg_given_incorrect_type(
|
|
|
138
135
|
"note_uuid": "00000000-0000-0000-0000-000000000000",
|
|
139
136
|
"coding": {"code": None, "system": "y"},
|
|
140
137
|
},
|
|
141
|
-
"1 validation error for ReasonForVisitCommand\ncoding.code\n Input should be a valid string [type=string_type",
|
|
142
138
|
{"note_uuid": "00000000-0000-0000-0000-000000000000"},
|
|
143
139
|
),
|
|
144
140
|
(
|
|
@@ -147,7 +143,6 @@ def test_command_raises_generic_error_when_kwarg_given_incorrect_type(
|
|
|
147
143
|
"note_uuid": "00000000-0000-0000-0000-000000000000",
|
|
148
144
|
"coding": {"system": "y"},
|
|
149
145
|
},
|
|
150
|
-
"1 validation error for ReasonForVisitCommand\ncoding.code\n Field required [type=missing",
|
|
151
146
|
{"note_uuid": "00000000-0000-0000-0000-000000000000"},
|
|
152
147
|
),
|
|
153
148
|
(
|
|
@@ -156,7 +151,6 @@ def test_command_raises_generic_error_when_kwarg_given_incorrect_type(
|
|
|
156
151
|
"note_uuid": "00000000-0000-0000-0000-000000000000",
|
|
157
152
|
"coding": {"code": "x", "system": 1},
|
|
158
153
|
},
|
|
159
|
-
"1 validation error for ReasonForVisitCommand\ncoding.system\n Input should be a valid string [type=string_type",
|
|
160
154
|
{"note_uuid": "00000000-0000-0000-0000-000000000000"},
|
|
161
155
|
),
|
|
162
156
|
(
|
|
@@ -165,7 +159,6 @@ def test_command_raises_generic_error_when_kwarg_given_incorrect_type(
|
|
|
165
159
|
"note_uuid": "00000000-0000-0000-0000-000000000000",
|
|
166
160
|
"coding": {"code": "x", "system": None},
|
|
167
161
|
},
|
|
168
|
-
"1 validation error for ReasonForVisitCommand\ncoding.system\n Input should be a valid string [type=string_type",
|
|
169
162
|
{"note_uuid": "00000000-0000-0000-0000-000000000000"},
|
|
170
163
|
),
|
|
171
164
|
(
|
|
@@ -174,7 +167,6 @@ def test_command_raises_generic_error_when_kwarg_given_incorrect_type(
|
|
|
174
167
|
"note_uuid": "00000000-0000-0000-0000-000000000000",
|
|
175
168
|
"coding": {"code": "x", "system": "y", "display": 1},
|
|
176
169
|
},
|
|
177
|
-
"1 validation error for ReasonForVisitCommand\ncoding.display\n Input should be a valid string [type=string_type",
|
|
178
170
|
{"note_uuid": "00000000-0000-0000-0000-000000000000"},
|
|
179
171
|
),
|
|
180
172
|
],
|
|
@@ -182,25 +174,24 @@ def test_command_raises_generic_error_when_kwarg_given_incorrect_type(
|
|
|
182
174
|
def test_command_raises_specific_error_when_kwarg_given_incorrect_type(
|
|
183
175
|
Command: type[PlanCommand] | type[ReasonForVisitCommand],
|
|
184
176
|
err_kwargs: dict,
|
|
185
|
-
err_msg: str,
|
|
186
177
|
valid_kwargs: dict,
|
|
187
178
|
) -> None:
|
|
188
179
|
"""Test that Command raises a specific error when a kwarg is given an incorrect type."""
|
|
189
|
-
with pytest.raises(ValidationError)
|
|
180
|
+
with pytest.raises(ValidationError):
|
|
190
181
|
cmd = Command(**err_kwargs)
|
|
191
182
|
cmd.originate()
|
|
183
|
+
cmd.command_uuid = str(uuid.uuid4())
|
|
192
184
|
cmd.edit()
|
|
193
|
-
assert err_msg in repr(e1.value)
|
|
194
185
|
|
|
195
186
|
cmd = Command(**valid_kwargs)
|
|
196
187
|
if len(err_kwargs) < len(valid_kwargs):
|
|
197
188
|
return
|
|
198
189
|
key, value = list(err_kwargs.items())[-1]
|
|
199
|
-
with pytest.raises(ValidationError)
|
|
190
|
+
with pytest.raises(ValidationError):
|
|
200
191
|
setattr(cmd, key, value)
|
|
201
192
|
cmd.originate()
|
|
193
|
+
cmd.command_uuid = str(uuid.uuid4())
|
|
202
194
|
cmd.edit()
|
|
203
|
-
assert err_msg in repr(e2.value)
|
|
204
195
|
|
|
205
196
|
|
|
206
197
|
@pytest.mark.parametrize(
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from .surescripts_messages import (
|
|
2
|
+
SendSurescriptsBenefitsRequestEffect,
|
|
3
|
+
SendSurescriptsEligibilityRequestEffect,
|
|
4
|
+
SendSurescriptsMedicationHistoryRequestEffect,
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"SendSurescriptsBenefitsRequestEffect",
|
|
9
|
+
"SendSurescriptsEligibilityRequestEffect",
|
|
10
|
+
"SendSurescriptsMedicationHistoryRequestEffect",
|
|
11
|
+
]
|
canvas_sdk/effects/task/task.py
CHANGED
|
@@ -23,6 +23,7 @@ class AddTask(_BaseEffect):
|
|
|
23
23
|
apply_required_fields = ("title",)
|
|
24
24
|
|
|
25
25
|
assignee_id: str | None = None
|
|
26
|
+
team_id: str | None = None
|
|
26
27
|
patient_id: str | None = None
|
|
27
28
|
title: str | None = None
|
|
28
29
|
due: datetime | None = None
|
|
@@ -36,6 +37,7 @@ class AddTask(_BaseEffect):
|
|
|
36
37
|
"patient": {"id": self.patient_id},
|
|
37
38
|
"due": self.due.isoformat() if self.due else None,
|
|
38
39
|
"assignee": {"id": self.assignee_id},
|
|
40
|
+
"team": {"id": self.team_id},
|
|
39
41
|
"title": self.title,
|
|
40
42
|
"status": self.status.value,
|
|
41
43
|
"labels": self.labels,
|
|
@@ -74,6 +76,7 @@ class UpdateTask(_BaseEffect):
|
|
|
74
76
|
|
|
75
77
|
id: str | None = None
|
|
76
78
|
assignee_id: str | None = None
|
|
79
|
+
team_id: str | None = None
|
|
77
80
|
patient_id: str | None = None
|
|
78
81
|
title: str | None = None
|
|
79
82
|
due: datetime | None = None
|
|
File without changes
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
from canvas_sdk.effects import Effect
|
|
8
|
+
from canvas_sdk.events import Event, EventRequest, EventType
|
|
9
|
+
from canvas_sdk.questionnaires import questionnaire_from_yaml
|
|
10
|
+
from plugin_runner.plugin_runner import LOADED_PLUGINS
|
|
11
|
+
from settings import PLUGIN_DIRECTORY
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.mark.parametrize("install_test_plugin", ["test_load_questionnaire"], indirect=True)
|
|
15
|
+
def test_from_yaml_valid_questionnaire(install_test_plugin: Path, load_test_plugins: None) -> None:
|
|
16
|
+
"""Test that the from_yaml function loads a valid questionnaire."""
|
|
17
|
+
plugin = LOADED_PLUGINS[
|
|
18
|
+
"test_load_questionnaire:test_load_questionnaire.protocols.my_protocol:ValidQuestionnaire"
|
|
19
|
+
]
|
|
20
|
+
result: list[Effect] = plugin["class"](Event(EventRequest(type=EventType.UNKNOWN))).compute()
|
|
21
|
+
|
|
22
|
+
assert (
|
|
23
|
+
yaml.load(
|
|
24
|
+
(
|
|
25
|
+
Path(PLUGIN_DIRECTORY)
|
|
26
|
+
/ "test_load_questionnaire/questionnaires/example_questionnaire.yml"
|
|
27
|
+
)
|
|
28
|
+
.resolve()
|
|
29
|
+
.read_text(),
|
|
30
|
+
Loader=yaml.SafeLoader,
|
|
31
|
+
).items()
|
|
32
|
+
<= json.loads(result[0].payload).items()
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.mark.parametrize("install_test_plugin", ["test_load_questionnaire"], indirect=True)
|
|
37
|
+
def test_from_yaml_invalid_questionnaire(
|
|
38
|
+
install_test_plugin: Path, load_test_plugins: None
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Test that the from_yaml function raises an error for invalid questionnaires."""
|
|
41
|
+
plugin = LOADED_PLUGINS[
|
|
42
|
+
"test_load_questionnaire:test_load_questionnaire.protocols.my_protocol:InvalidQuestionnaire"
|
|
43
|
+
]
|
|
44
|
+
with pytest.raises(FileNotFoundError):
|
|
45
|
+
plugin["class"](Event(EventRequest(type=EventType.UNKNOWN))).compute()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.mark.parametrize("install_test_plugin", ["test_load_questionnaire"], indirect=True)
|
|
49
|
+
def test_from_yaml_forbidden_questionnaire(
|
|
50
|
+
install_test_plugin: Path, load_test_plugins: None
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Test that the from_yaml function raises an error for a questionnaire outside plugin package."""
|
|
53
|
+
plugin = LOADED_PLUGINS[
|
|
54
|
+
"test_load_questionnaire:test_load_questionnaire.protocols.my_protocol:ForbiddenQuestionnaire"
|
|
55
|
+
]
|
|
56
|
+
with pytest.raises(PermissionError):
|
|
57
|
+
plugin["class"](Event(EventRequest(type=EventType.UNKNOWN))).compute()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_from_yaml_non_plugin_caller() -> None:
|
|
61
|
+
"""Test that the from_yaml function returns None when called outside a plugin."""
|
|
62
|
+
assert questionnaire_from_yaml("questionnaires/example_questionnaire.yml") is None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@pytest.mark.parametrize("install_test_plugin", ["test_load_questionnaire"], indirect=True)
|
|
66
|
+
def test_from_yaml_sets_default_values(install_test_plugin: Path) -> None:
|
|
67
|
+
"""Test that the from_yaml function sets default values for properties."""
|
|
68
|
+
globals()["__is_plugin__"] = True
|
|
69
|
+
globals()["__name__"] = "test_load_questionnaire"
|
|
70
|
+
|
|
71
|
+
definition = questionnaire_from_yaml("questionnaires/example_questionnaire.yml")
|
|
72
|
+
|
|
73
|
+
assert definition is not None
|
|
74
|
+
assert definition["display_results_in_social_history_section"] is False
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import json
|
|
3
|
+
from collections.abc import Generator
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, TypedDict
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
from jsonschema import Draft7Validator, validators
|
|
9
|
+
|
|
10
|
+
from canvas_sdk.utils.plugins import plugin_only
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Response(TypedDict):
|
|
14
|
+
"""A Response of a Questionnaire."""
|
|
15
|
+
|
|
16
|
+
name: str
|
|
17
|
+
code: str
|
|
18
|
+
code_description: str
|
|
19
|
+
value: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Question(TypedDict):
|
|
23
|
+
"""A Question of a Questionnaire."""
|
|
24
|
+
|
|
25
|
+
code_system: str
|
|
26
|
+
code: str
|
|
27
|
+
code_description: str
|
|
28
|
+
content: str
|
|
29
|
+
responses_code_system: str
|
|
30
|
+
responses_type: str
|
|
31
|
+
display_result_in_social_history_section: bool
|
|
32
|
+
responses: list[Response]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class QuestionnaireConfig(TypedDict):
|
|
36
|
+
"""A Questionnaire configuration."""
|
|
37
|
+
|
|
38
|
+
name: str
|
|
39
|
+
form_type: str
|
|
40
|
+
code_system: str
|
|
41
|
+
code: str
|
|
42
|
+
can_originate_in_charting: bool
|
|
43
|
+
prologue: str
|
|
44
|
+
display_results_in_social_history_section: bool
|
|
45
|
+
questions: list[Question]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def extend_with_defaults(validator_class: type[Draft7Validator]) -> type[Draft7Validator]:
|
|
49
|
+
"""Extend a Draft7Validator with default values for properties."""
|
|
50
|
+
validate_properties = validator_class.VALIDATORS["properties"]
|
|
51
|
+
|
|
52
|
+
def set_defaults(
|
|
53
|
+
validator: Draft7Validator,
|
|
54
|
+
properties: dict[str, Any],
|
|
55
|
+
instance: dict[str, Any],
|
|
56
|
+
schema: dict[str, Any],
|
|
57
|
+
) -> Generator[Any, None, None]:
|
|
58
|
+
for property, subschema in properties.items():
|
|
59
|
+
if "default" in subschema:
|
|
60
|
+
instance.setdefault(property, subschema["default"])
|
|
61
|
+
|
|
62
|
+
yield from validate_properties(
|
|
63
|
+
validator,
|
|
64
|
+
properties,
|
|
65
|
+
instance,
|
|
66
|
+
schema,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return validators.extend(
|
|
70
|
+
validator_class,
|
|
71
|
+
{"properties": set_defaults},
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
ExtendedDraft7Validator = extend_with_defaults(Draft7Validator)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@plugin_only
|
|
79
|
+
def from_yaml(questionnaire_name: str, **kwargs: Any) -> QuestionnaireConfig | None:
|
|
80
|
+
"""Load a Questionnaire configuration from a YAML file.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
questionnaire_name (str): The path to the questionnaire file, relative to the plugin package.
|
|
84
|
+
If the path starts with a forward slash ("/"), it will be stripped during resolution.
|
|
85
|
+
kwargs (Any): Additional keyword arguments.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
QuestionnaireConfig: The loaded Questionnaire configuration.
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
FileNotFoundError: If the questionnaire file does not exist within the plugin's directory
|
|
92
|
+
or if the resolved path is invalid.
|
|
93
|
+
PermissionError: If the resolved path is outside the plugin's directory.
|
|
94
|
+
ValidationError: If the questionnaire file does not conform to the JSON schema.
|
|
95
|
+
"""
|
|
96
|
+
plugin_dir = kwargs["plugin_dir"]
|
|
97
|
+
questionnaire_config_path = Path(plugin_dir / questionnaire_name.lstrip("/")).resolve()
|
|
98
|
+
|
|
99
|
+
if not questionnaire_config_path.is_relative_to(plugin_dir):
|
|
100
|
+
raise PermissionError(f"Invalid Questionnaire '{questionnaire_name}'")
|
|
101
|
+
elif not questionnaire_config_path.exists():
|
|
102
|
+
raise FileNotFoundError(f"Questionnaire {questionnaire_name} not found.")
|
|
103
|
+
|
|
104
|
+
questionnaire_config = yaml.load(questionnaire_config_path.read_text(), Loader=yaml.SafeLoader)
|
|
105
|
+
ExtendedDraft7Validator(json_schema()).validate(questionnaire_config)
|
|
106
|
+
|
|
107
|
+
return questionnaire_config
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@functools.cache
|
|
111
|
+
def json_schema() -> dict[str, Any]:
|
|
112
|
+
"""Reads the JSON schema for a Questionnaire Config."""
|
|
113
|
+
schema = json.loads(
|
|
114
|
+
(Path(__file__).resolve().parent.parent.parent / "schemas/questionnaire.json").read_text()
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
return schema
|
canvas_sdk/templates/utils.py
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import inspect
|
|
2
1
|
from pathlib import Path
|
|
3
2
|
from typing import Any
|
|
4
3
|
|
|
5
4
|
from django.template import Context, Template
|
|
6
5
|
|
|
7
|
-
from
|
|
6
|
+
from canvas_sdk.utils.plugins import plugin_only
|
|
8
7
|
|
|
9
8
|
|
|
10
|
-
|
|
9
|
+
@plugin_only
|
|
10
|
+
def render_to_string(
|
|
11
|
+
template_name: str, context: dict[str, Any] | None = None, **kwargs: Any
|
|
12
|
+
) -> str | None:
|
|
11
13
|
"""Load a template and render it with the given context.
|
|
12
14
|
|
|
13
15
|
Args:
|
|
@@ -15,6 +17,7 @@ def render_to_string(template_name: str, context: dict[str, Any] | None = None)
|
|
|
15
17
|
If the path starts with a forward slash ("/"), it will be stripped during resolution.
|
|
16
18
|
context (dict[str, Any] | None): A dictionary of variables to pass to the template
|
|
17
19
|
for rendering. Defaults to None, which uses an empty context.
|
|
20
|
+
kwargs (Any): Additional keyword arguments.
|
|
18
21
|
|
|
19
22
|
Returns:
|
|
20
23
|
str: The rendered template as a string.
|
|
@@ -23,15 +26,7 @@ def render_to_string(template_name: str, context: dict[str, Any] | None = None)
|
|
|
23
26
|
FileNotFoundError: If the template file does not exist within the plugin's directory
|
|
24
27
|
or if the resolved path is invalid.
|
|
25
28
|
"""
|
|
26
|
-
|
|
27
|
-
current_frame = inspect.currentframe()
|
|
28
|
-
caller = current_frame.f_back if current_frame else None
|
|
29
|
-
|
|
30
|
-
if not caller or "__is_plugin__" not in caller.f_globals:
|
|
31
|
-
return None
|
|
32
|
-
|
|
33
|
-
plugin_name = caller.f_globals["__name__"].split(".")[0]
|
|
34
|
-
plugin_dir = plugins_dir / plugin_name
|
|
29
|
+
plugin_dir = kwargs["plugin_dir"]
|
|
35
30
|
template_path = Path(plugin_dir / template_name.lstrip("/")).resolve()
|
|
36
31
|
|
|
37
32
|
if not template_path.is_relative_to(plugin_dir):
|
canvas_sdk/utils/__init__.py
CHANGED
canvas_sdk/utils/http.py
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import concurrent
|
|
2
|
+
import functools
|
|
1
3
|
import time
|
|
2
|
-
from collections.abc import Callable, Mapping
|
|
4
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
3
6
|
from functools import wraps
|
|
4
|
-
from typing import Any, TypeVar, cast
|
|
7
|
+
from typing import Any, Literal, Protocol, TypeVar, cast
|
|
5
8
|
|
|
6
9
|
import requests
|
|
7
10
|
import statsd
|
|
@@ -9,9 +12,90 @@ import statsd
|
|
|
9
12
|
F = TypeVar("F", bound=Callable)
|
|
10
13
|
|
|
11
14
|
|
|
15
|
+
class _BatchableRequest:
|
|
16
|
+
"""Representation of a request that will be executed in parallel with other requests."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self, method: Literal["GET", "POST", "PUT", "PATCH"], url: str, **kwargs: Any
|
|
20
|
+
) -> None:
|
|
21
|
+
self._method = method
|
|
22
|
+
self._url = url
|
|
23
|
+
self._kwargs = kwargs
|
|
24
|
+
|
|
25
|
+
def fn(self, client: "Http") -> Callable:
|
|
26
|
+
"""
|
|
27
|
+
Return a callable constructed from an Http object and the method, URL, and kwargs.
|
|
28
|
+
|
|
29
|
+
The callable is passed to the ThreadPoolExecutor.
|
|
30
|
+
"""
|
|
31
|
+
client_method: Callable
|
|
32
|
+
match self._method:
|
|
33
|
+
case "GET":
|
|
34
|
+
client_method = client.get
|
|
35
|
+
case "POST":
|
|
36
|
+
client_method = client.post
|
|
37
|
+
case "PUT":
|
|
38
|
+
client_method = client.put
|
|
39
|
+
case "PATCH":
|
|
40
|
+
client_method = client.patch
|
|
41
|
+
case _:
|
|
42
|
+
raise ValueError(f"HTTP method {self._method} is not supported")
|
|
43
|
+
|
|
44
|
+
return functools.partial(client_method, self._url, **self._kwargs)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class BatchableRequest(Protocol):
|
|
48
|
+
"""Protocol for batchable requests."""
|
|
49
|
+
|
|
50
|
+
def fn(self, client: "Http") -> Callable:
|
|
51
|
+
"""
|
|
52
|
+
Return a callable that can be passed to the ThreadPoolExecutor.
|
|
53
|
+
"""
|
|
54
|
+
...
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def batch_get(
|
|
58
|
+
url: str, headers: Mapping[str, str | bytes | None] | None = None
|
|
59
|
+
) -> BatchableRequest:
|
|
60
|
+
"""Return a batchable GET request."""
|
|
61
|
+
return _BatchableRequest("GET", url, headers=headers)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def batch_post(
|
|
65
|
+
url: str,
|
|
66
|
+
json: dict | None = None,
|
|
67
|
+
data: dict | str | list | bytes | None = None,
|
|
68
|
+
headers: Mapping[str, str | bytes | None] | None = None,
|
|
69
|
+
) -> BatchableRequest:
|
|
70
|
+
"""Return a batchable POST request."""
|
|
71
|
+
return _BatchableRequest("POST", url, json=json, data=data, headeres=headers)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def batch_put(
|
|
75
|
+
url: str,
|
|
76
|
+
json: dict | None = None,
|
|
77
|
+
data: dict | str | list | bytes | None = None,
|
|
78
|
+
headers: Mapping[str, str | bytes | None] | None = None,
|
|
79
|
+
) -> BatchableRequest:
|
|
80
|
+
"""Return a batchable PUT request."""
|
|
81
|
+
return _BatchableRequest("PUT", url, json=json, data=data, headers=headers)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def batch_patch(
|
|
85
|
+
url: str,
|
|
86
|
+
json: dict | None = None,
|
|
87
|
+
data: dict | str | list | bytes | None = None,
|
|
88
|
+
headers: Mapping[str, str | bytes | None] | None = None,
|
|
89
|
+
) -> BatchableRequest:
|
|
90
|
+
"""Return a batchable PATCH request."""
|
|
91
|
+
return _BatchableRequest("PATCH", url, json=json, data=data, headers=headers)
|
|
92
|
+
|
|
93
|
+
|
|
12
94
|
class Http:
|
|
13
95
|
"""A helper class for completing HTTP calls with metrics tracking."""
|
|
14
96
|
|
|
97
|
+
_MAX_WORKER_TIMEOUT_SECONDS = 30
|
|
98
|
+
|
|
15
99
|
def __init__(self) -> None:
|
|
16
100
|
self.session = requests.Session()
|
|
17
101
|
self.statsd_client = statsd.StatsClient()
|
|
@@ -72,3 +156,29 @@ class Http:
|
|
|
72
156
|
) -> requests.Response:
|
|
73
157
|
"""Sends a PATCH request."""
|
|
74
158
|
return self.session.patch(url, json=json, data=data, headers=headers)
|
|
159
|
+
|
|
160
|
+
@measure_time
|
|
161
|
+
def batch_requests(
|
|
162
|
+
self,
|
|
163
|
+
batch_requests: Iterable[BatchableRequest],
|
|
164
|
+
timeout: int | None = None,
|
|
165
|
+
) -> list[requests.Response]:
|
|
166
|
+
"""
|
|
167
|
+
Execute requests in parallel.
|
|
168
|
+
|
|
169
|
+
Wait for the responses to complete, and then return a list of the responses in the same
|
|
170
|
+
ordering as the requests.
|
|
171
|
+
"""
|
|
172
|
+
if timeout is None:
|
|
173
|
+
timeout = self._MAX_WORKER_TIMEOUT_SECONDS
|
|
174
|
+
elif timeout < 1 or timeout > self._MAX_WORKER_TIMEOUT_SECONDS:
|
|
175
|
+
raise ValueError(
|
|
176
|
+
f"Timeout value must be greater than 0 and less than or equal to {self._MAX_WORKER_TIMEOUT_SECONDS} seconds"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
with ThreadPoolExecutor() as executor:
|
|
180
|
+
futures = [executor.submit(request.fn(self)) for request in batch_requests]
|
|
181
|
+
|
|
182
|
+
concurrent.futures.wait(futures, timeout=timeout)
|
|
183
|
+
|
|
184
|
+
return [future.result() for future in futures]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from settings import PLUGIN_DIRECTORY
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def plugin_only(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
10
|
+
"""Decorator to restrict a function's execution to plugins only."""
|
|
11
|
+
|
|
12
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
13
|
+
current_frame = inspect.currentframe()
|
|
14
|
+
caller = current_frame.f_back if current_frame else None
|
|
15
|
+
|
|
16
|
+
if not caller or "__is_plugin__" not in caller.f_globals:
|
|
17
|
+
return None
|
|
18
|
+
|
|
19
|
+
plugin_name = caller.f_globals["__name__"].split(".")[0]
|
|
20
|
+
plugin_dir = Path(PLUGIN_DIRECTORY) / plugin_name
|
|
21
|
+
kwargs["plugin_dir"] = plugin_dir.resolve()
|
|
22
|
+
|
|
23
|
+
return func(*args, **kwargs)
|
|
24
|
+
|
|
25
|
+
return wrapper
|
canvas_sdk/v1/data/__init__.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from .allergy_intolerance import AllergyIntolerance, AllergyIntoleranceCoding
|
|
2
2
|
from .appointment import Appointment
|
|
3
3
|
from .assessment import Assessment
|
|
4
|
-
from .billing import BillingLineItem
|
|
4
|
+
from .billing import BillingLineItem, BillingLineItemModifier
|
|
5
5
|
from .care_team import CareTeamMembership, CareTeamRole
|
|
6
6
|
from .command import Command
|
|
7
7
|
from .condition import Condition, ConditionCoding
|
|
@@ -48,6 +48,7 @@ from .questionnaire import (
|
|
|
48
48
|
ResponseOption,
|
|
49
49
|
ResponseOptionSet,
|
|
50
50
|
)
|
|
51
|
+
from .reason_for_visit import ReasonForVisitSettingCoding
|
|
51
52
|
from .staff import Staff
|
|
52
53
|
from .task import Task, TaskComment, TaskLabel, TaskTaskLabel
|
|
53
54
|
from .user import CanvasUser
|
|
@@ -58,6 +59,7 @@ __all__ = [
|
|
|
58
59
|
"AllergyIntoleranceCoding",
|
|
59
60
|
"Assessment",
|
|
60
61
|
"BillingLineItem",
|
|
62
|
+
"BillingLineItemModifier",
|
|
61
63
|
"CanvasUser",
|
|
62
64
|
"CareTeamMembership",
|
|
63
65
|
"CareTeamRole",
|
|
@@ -103,6 +105,7 @@ __all__ = [
|
|
|
103
105
|
"Question",
|
|
104
106
|
"Questionnaire",
|
|
105
107
|
"QuestionnaireQuestionMap",
|
|
108
|
+
"ReasonForVisitSettingCoding",
|
|
106
109
|
"ResponseOption",
|
|
107
110
|
"ResponseOptionSet",
|
|
108
111
|
"Staff",
|