canvas 0.27.0__py3-none-any.whl → 0.29.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.27.0.dist-info → canvas-0.29.0.dist-info}/METADATA +1 -1
- {canvas-0.27.0.dist-info → canvas-0.29.0.dist-info}/RECORD +31 -28
- canvas_cli/apps/auth/tests.py +3 -0
- canvas_cli/apps/emit/event_fixtures/SIMPLE_API_REQUEST.ndjson +1 -0
- canvas_cli/templates/plugins/application/{{ cookiecutter.__project_slug }}/CANVAS_MANIFEST.json +6 -4
- canvas_cli/utils/validators/manifest_schema.py +3 -0
- canvas_generated/messages/effects_pb2.py +2 -2
- canvas_generated/messages/effects_pb2.pyi +4 -0
- canvas_generated/messages/events_pb2.py +2 -2
- canvas_generated/messages/events_pb2.pyi +4 -0
- canvas_sdk/commands/commands/perform.py +1 -1
- canvas_sdk/effects/patient_portal/application_configuration.py +22 -0
- canvas_sdk/effects/show_button.py +2 -1
- canvas_sdk/effects/simple_api.py +2 -2
- canvas_sdk/handlers/action_button.py +6 -1
- canvas_sdk/handlers/simple_api/api.py +37 -20
- canvas_sdk/tests/handlers/test_simple_api.py +122 -2
- canvas_sdk/utils/http.py +98 -10
- canvas_sdk/utils/tests.py +6 -1
- canvas_sdk/v1/data/__init__.py +2 -0
- canvas_sdk/v1/data/banner_alert.py +27 -0
- canvas_sdk/v1/data/lab.py +2 -2
- canvas_sdk/v1/data/staff.py +3 -4
- plugin_runner/authentication.py +2 -1
- plugin_runner/plugin_runner.py +6 -3
- plugin_runner/sandbox.py +2 -2
- protobufs/canvas_generated/messages/effects.proto +2 -0
- protobufs/canvas_generated/messages/events.proto +2 -0
- settings.py +2 -0
- {canvas-0.27.0.dist-info → canvas-0.29.0.dist-info}/WHEEL +0 -0
- {canvas-0.27.0.dist-info → canvas-0.29.0.dist-info}/entry_points.txt +0 -0
|
@@ -786,6 +786,8 @@ class EventType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
|
|
786
786
|
PATIENT_PORTAL__APPOINTMENTS__FORM_LOCATIONS__POST_SEARCH: _ClassVar[EventType]
|
|
787
787
|
PATIENT_PORTAL__APPOINTMENTS__FORM_PROVIDERS__PRE_SEARCH: _ClassVar[EventType]
|
|
788
788
|
PATIENT_PORTAL__APPOINTMENTS__FORM_PROVIDERS__POST_SEARCH: _ClassVar[EventType]
|
|
789
|
+
PATIENT_PORTAL__APPOINTMENT_CAN_SHOW_MEETING_LINK: _ClassVar[EventType]
|
|
790
|
+
PATIENT_PORTAL__GET_APPLICATION_CONFIGURATION: _ClassVar[EventType]
|
|
789
791
|
PATIENT_PORTAL__MENU_CONFIGURATION: _ClassVar[EventType]
|
|
790
792
|
PATIENT_PORTAL__WIDGET_CONFIGURATION: _ClassVar[EventType]
|
|
791
793
|
SHOW_CHART_SUMMARY_SOCIAL_DETERMINANTS_SECTION_BUTTON: _ClassVar[EventType]
|
|
@@ -1578,6 +1580,8 @@ PATIENT_PORTAL__APPOINTMENTS__FORM_LOCATIONS__PRE_SEARCH: EventType
|
|
|
1578
1580
|
PATIENT_PORTAL__APPOINTMENTS__FORM_LOCATIONS__POST_SEARCH: EventType
|
|
1579
1581
|
PATIENT_PORTAL__APPOINTMENTS__FORM_PROVIDERS__PRE_SEARCH: EventType
|
|
1580
1582
|
PATIENT_PORTAL__APPOINTMENTS__FORM_PROVIDERS__POST_SEARCH: EventType
|
|
1583
|
+
PATIENT_PORTAL__APPOINTMENT_CAN_SHOW_MEETING_LINK: EventType
|
|
1584
|
+
PATIENT_PORTAL__GET_APPLICATION_CONFIGURATION: EventType
|
|
1581
1585
|
PATIENT_PORTAL__MENU_CONFIGURATION: EventType
|
|
1582
1586
|
PATIENT_PORTAL__WIDGET_CONFIGURATION: EventType
|
|
1583
1587
|
SHOW_CHART_SUMMARY_SOCIAL_DETERMINANTS_SECTION_BUTTON: EventType
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from canvas_sdk.effects.base import EffectType, _BaseEffect
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PatientPortalApplicationConfiguration(_BaseEffect):
|
|
7
|
+
"""An effect to configure patient portal application."""
|
|
8
|
+
|
|
9
|
+
class Meta:
|
|
10
|
+
effect_type = EffectType.PATIENT_PORTAL__APPLICATION_CONFIGURATION
|
|
11
|
+
|
|
12
|
+
can_schedule_appointments: bool
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def values(self) -> dict[str, Any]:
|
|
16
|
+
"""Application Configuration values."""
|
|
17
|
+
return {"can_schedule_appointments": self.can_schedule_appointments}
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def effect_payload(self) -> dict[str, Any]:
|
|
21
|
+
"""The payload of the effect."""
|
|
22
|
+
return {"data": self.values}
|
|
@@ -16,11 +16,12 @@ class ShowButtonEffect(_BaseEffect):
|
|
|
16
16
|
|
|
17
17
|
key: str = Field(min_length=1)
|
|
18
18
|
title: str = Field(min_length=1)
|
|
19
|
+
priority: int = Field(default=0)
|
|
19
20
|
|
|
20
21
|
@property
|
|
21
22
|
def values(self) -> dict[str, Any]:
|
|
22
23
|
"""The ShowButtonEffect's values."""
|
|
23
|
-
return {"key": self.key, "title": self.title}
|
|
24
|
+
return {"key": self.key, "title": self.title, "priority": self.priority}
|
|
24
25
|
|
|
25
26
|
@property
|
|
26
27
|
def effect_payload(self) -> dict[str, Any]:
|
canvas_sdk/effects/simple_api.py
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import json
|
|
2
2
|
from base64 import b64encode
|
|
3
|
-
from collections.abc import Mapping
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
4
|
from http import HTTPStatus
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
7
|
from canvas_generated.messages.effects_pb2 import EffectType
|
|
8
8
|
from canvas_sdk.effects import Effect, _BaseEffect
|
|
9
9
|
|
|
10
|
-
JSON =
|
|
10
|
+
JSON = Mapping[str, "JSON"] | Sequence["JSON"] | int | float | str | bool | None
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
class Response(_BaseEffect):
|
|
@@ -48,6 +48,7 @@ class ActionButton(BaseHandler):
|
|
|
48
48
|
BUTTON_TITLE: str = ""
|
|
49
49
|
BUTTON_KEY: str = ""
|
|
50
50
|
BUTTON_LOCATION: ButtonLocation
|
|
51
|
+
PRIORITY: int = 0
|
|
51
52
|
|
|
52
53
|
@abstractmethod
|
|
53
54
|
def handle(self) -> list[Effect]:
|
|
@@ -68,7 +69,11 @@ class ActionButton(BaseHandler):
|
|
|
68
69
|
if show_button_event_match:
|
|
69
70
|
location = show_button_event_match.group(1)
|
|
70
71
|
if self.ButtonLocation[location] == self.BUTTON_LOCATION and self.visible():
|
|
71
|
-
return [
|
|
72
|
+
return [
|
|
73
|
+
ShowButtonEffect(
|
|
74
|
+
key=self.BUTTON_KEY, title=self.BUTTON_TITLE, priority=self.PRIORITY
|
|
75
|
+
).apply()
|
|
76
|
+
]
|
|
72
77
|
elif self.context["key"] == self.BUTTON_KEY:
|
|
73
78
|
return self.handle()
|
|
74
79
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import re
|
|
2
3
|
import traceback
|
|
3
4
|
from abc import ABC
|
|
4
5
|
from base64 import b64decode
|
|
@@ -9,7 +10,7 @@ from typing import Any, ClassVar, Protocol, TypeVar, cast
|
|
|
9
10
|
from urllib.parse import parse_qsl
|
|
10
11
|
|
|
11
12
|
from canvas_sdk.effects import Effect, EffectType
|
|
12
|
-
from canvas_sdk.effects.simple_api import JSONResponse, Response
|
|
13
|
+
from canvas_sdk.effects.simple_api import JSON, JSONResponse, Response
|
|
13
14
|
from canvas_sdk.events import Event, EventType
|
|
14
15
|
from canvas_sdk.handlers.base import BaseHandler
|
|
15
16
|
from logger import log
|
|
@@ -19,14 +20,6 @@ from .exceptions import AuthenticationError, InvalidCredentialsError
|
|
|
19
20
|
from .security import Credentials
|
|
20
21
|
from .tools import CaseInsensitiveMultiDict, MultiDict, separate_headers
|
|
21
22
|
|
|
22
|
-
# TODO: Routing by path regex?
|
|
23
|
-
# TODO: Log requests in a format similar to other API frameworks (probably need effect metadata)
|
|
24
|
-
# TODO: Support Effect metadata that is separate from payload
|
|
25
|
-
# TODO: Encode event payloads with MessagePack instead of JSON
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
JSON = dict[str, "JSON"] | list["JSON"] | int | float | str | bool | None
|
|
29
|
-
|
|
30
23
|
|
|
31
24
|
class FormPart(Protocol):
|
|
32
25
|
"""
|
|
@@ -162,13 +155,16 @@ def parse_multipart_form(form: bytes, boundary: str) -> MultiDict[str, FormPart]
|
|
|
162
155
|
class Request:
|
|
163
156
|
"""Request class for incoming requests to the API."""
|
|
164
157
|
|
|
165
|
-
def __init__(self, event: Event) -> None:
|
|
158
|
+
def __init__(self, event: Event, path_pattern: re.Pattern) -> None:
|
|
166
159
|
self.method = event.context["method"]
|
|
167
160
|
self.path = event.context["path"]
|
|
168
161
|
self.query_string = event.context["query_string"]
|
|
169
162
|
self._body = event.context["body"]
|
|
170
163
|
self.headers = CaseInsensitiveMultiDict(separate_headers(event.context["headers"]))
|
|
171
164
|
|
|
165
|
+
match = path_pattern.match(event.context["path"])
|
|
166
|
+
self.path_params = match.groupdict() if match else {}
|
|
167
|
+
|
|
172
168
|
self.query_params = MultiDict(parse_qsl(self.query_string))
|
|
173
169
|
|
|
174
170
|
# Parse the content type and any included content type parameters
|
|
@@ -266,7 +262,8 @@ class SimpleAPIBase(BaseHandler, ABC):
|
|
|
266
262
|
EventType.Name(EventType.SIMPLE_API_REQUEST),
|
|
267
263
|
]
|
|
268
264
|
|
|
269
|
-
_ROUTES: ClassVar[dict[
|
|
265
|
+
_ROUTES: ClassVar[dict[str, list[tuple[re.Pattern, RouteHandler]]]]
|
|
266
|
+
_PATH_PARAM_REGEX = re.compile("<([a-zA-Z_][a-zA-Z0-9_]*)>")
|
|
270
267
|
|
|
271
268
|
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
272
269
|
super().__init_subclass__(**kwargs)
|
|
@@ -279,7 +276,30 @@ class SimpleAPIBase(BaseHandler, ABC):
|
|
|
279
276
|
if callable(attr) and (route := getattr(attr, "route", None)):
|
|
280
277
|
method, relative_path = route
|
|
281
278
|
path = f"{cls._path_prefix()}{relative_path}"
|
|
282
|
-
|
|
279
|
+
|
|
280
|
+
# Convert the path to a regular expression pattern so that any path parameters can
|
|
281
|
+
# be extracted later
|
|
282
|
+
try:
|
|
283
|
+
path_pattern = re.compile(path.replace("<", "(?P<").replace(">", ">[^/]+)"))
|
|
284
|
+
except re.error as error:
|
|
285
|
+
raise PluginError(
|
|
286
|
+
f"Path parameter names in route '{path}' must be unique"
|
|
287
|
+
) from error
|
|
288
|
+
|
|
289
|
+
cls._ROUTES.setdefault(method, [])
|
|
290
|
+
cls._ROUTES[method].append((path_pattern, attr))
|
|
291
|
+
|
|
292
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
293
|
+
super().__init__(*args, **kwargs)
|
|
294
|
+
|
|
295
|
+
# Determine the first handler that matches the path based on the path pattern
|
|
296
|
+
self._path_pattern = None
|
|
297
|
+
self._handler = None
|
|
298
|
+
for path_pattern, handler in self._ROUTES.get(self.event.context["method"], ()):
|
|
299
|
+
if path_pattern.match(self.event.context["path"]):
|
|
300
|
+
self._path_pattern = path_pattern
|
|
301
|
+
self._handler = handler
|
|
302
|
+
break
|
|
283
303
|
|
|
284
304
|
@classmethod
|
|
285
305
|
def _path_prefix(cls) -> str:
|
|
@@ -288,7 +308,7 @@ class SimpleAPIBase(BaseHandler, ABC):
|
|
|
288
308
|
@cached_property
|
|
289
309
|
def request(self) -> Request:
|
|
290
310
|
"""Return the request object from the event."""
|
|
291
|
-
return Request(self.event)
|
|
311
|
+
return Request(self.event, cast(re.Pattern, self._path_pattern))
|
|
292
312
|
|
|
293
313
|
def compute(self) -> list[Effect]:
|
|
294
314
|
"""Handle the authenticate or request event."""
|
|
@@ -313,8 +333,8 @@ class SimpleAPIBase(BaseHandler, ABC):
|
|
|
313
333
|
credentials_cls = self.authenticate.__annotations__.get("credentials")
|
|
314
334
|
if not credentials_cls or not issubclass(credentials_cls, Credentials):
|
|
315
335
|
raise PluginError(
|
|
316
|
-
"Cannot determine authentication scheme
|
|
317
|
-
"credentials your endpoint requires"
|
|
336
|
+
f"Cannot determine authentication scheme for {self.request.path}; "
|
|
337
|
+
"please specify the type of credentials your endpoint requires"
|
|
318
338
|
)
|
|
319
339
|
credentials = credentials_cls(self.request)
|
|
320
340
|
|
|
@@ -334,11 +354,8 @@ class SimpleAPIBase(BaseHandler, ABC):
|
|
|
334
354
|
|
|
335
355
|
def _handle_request(self) -> list[Effect]:
|
|
336
356
|
"""Route the incoming request to the handler method based on the HTTP method and path."""
|
|
337
|
-
# Get the handler method
|
|
338
|
-
handler = self._ROUTES[(self.request.method, self.request.path)]
|
|
339
|
-
|
|
340
357
|
# Handle the request
|
|
341
|
-
effects =
|
|
358
|
+
effects = cast(RouteHandler, self._handler)(self)
|
|
342
359
|
|
|
343
360
|
# Iterate over the returned effects and:
|
|
344
361
|
# 1. Change any response objects to response effects
|
|
@@ -377,7 +394,7 @@ class SimpleAPIBase(BaseHandler, ABC):
|
|
|
377
394
|
|
|
378
395
|
def accept_event(self) -> bool:
|
|
379
396
|
"""Ignore the event if the handler does not implement the route."""
|
|
380
|
-
return
|
|
397
|
+
return self._handler is not None
|
|
381
398
|
|
|
382
399
|
def authenticate(self, credentials: Credentials) -> bool:
|
|
383
400
|
"""Method the user should override to authenticate requests."""
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import re
|
|
2
3
|
from base64 import b64decode, b64encode
|
|
3
4
|
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
4
5
|
from http import HTTPStatus
|
|
@@ -242,7 +243,8 @@ def test_request(
|
|
|
242
243
|
path = "/route"
|
|
243
244
|
query_string = "value1=a&value2=b"
|
|
244
245
|
request = Request(
|
|
245
|
-
make_event(EventType.SIMPLE_API_REQUEST, method, path, query_string, body, headers)
|
|
246
|
+
make_event(EventType.SIMPLE_API_REQUEST, method, path, query_string, body, headers),
|
|
247
|
+
path_pattern=re.compile(path),
|
|
246
248
|
)
|
|
247
249
|
|
|
248
250
|
assert request.method == method
|
|
@@ -317,7 +319,8 @@ def test_request_form(
|
|
|
317
319
|
path="/route",
|
|
318
320
|
body=body,
|
|
319
321
|
headers={"Content-Type": content_type},
|
|
320
|
-
)
|
|
322
|
+
),
|
|
323
|
+
path_pattern=re.compile("/route"),
|
|
321
324
|
)
|
|
322
325
|
|
|
323
326
|
assert request.form_data() == expected_form_data
|
|
@@ -435,6 +438,112 @@ def test_request_routing_api(
|
|
|
435
438
|
assert body["method"] == method
|
|
436
439
|
|
|
437
440
|
|
|
441
|
+
@pytest.mark.parametrize(
|
|
442
|
+
argnames="prefix_pattern,path_pattern,body_func,path,expected_body",
|
|
443
|
+
argvalues=[
|
|
444
|
+
(
|
|
445
|
+
"/prefix",
|
|
446
|
+
"/path/<param>",
|
|
447
|
+
lambda params: {"param": params["param"]},
|
|
448
|
+
"/prefix/path/value",
|
|
449
|
+
{"param": "value"},
|
|
450
|
+
),
|
|
451
|
+
(
|
|
452
|
+
"/prefix",
|
|
453
|
+
"/path1/<param1>/path2/<param2>",
|
|
454
|
+
lambda params: {"param1": params["param1"], "param2": params["param2"]},
|
|
455
|
+
"/prefix/path1/value1/path2/value2",
|
|
456
|
+
{"param1": "value1", "param2": "value2"},
|
|
457
|
+
),
|
|
458
|
+
(
|
|
459
|
+
"/prefix/<param>",
|
|
460
|
+
"/path",
|
|
461
|
+
lambda params: {"param": params["param"]},
|
|
462
|
+
"/prefix/value/path",
|
|
463
|
+
{"param": "value"},
|
|
464
|
+
),
|
|
465
|
+
(
|
|
466
|
+
"/<param1>/prefix/<param2>",
|
|
467
|
+
"/path",
|
|
468
|
+
lambda params: {"param1": params["param1"], "param2": params["param2"]},
|
|
469
|
+
"/value1/prefix/value2/path",
|
|
470
|
+
{"param1": "value1", "param2": "value2"},
|
|
471
|
+
),
|
|
472
|
+
(
|
|
473
|
+
"/prefix/<param1>",
|
|
474
|
+
"/path/<param2>",
|
|
475
|
+
lambda params: {"param1": params["param1"], "param2": params["param2"]},
|
|
476
|
+
"/prefix/value1/path/value2",
|
|
477
|
+
{"param1": "value1", "param2": "value2"},
|
|
478
|
+
),
|
|
479
|
+
],
|
|
480
|
+
ids=[
|
|
481
|
+
"single parameter in path",
|
|
482
|
+
"multiple parameters in path",
|
|
483
|
+
"single parameter in prefix",
|
|
484
|
+
"multiple parameters in prefix",
|
|
485
|
+
"parameters in path and prefix",
|
|
486
|
+
],
|
|
487
|
+
)
|
|
488
|
+
def test_request_routing_path_pattern(
|
|
489
|
+
prefix_pattern: str,
|
|
490
|
+
path_pattern: str,
|
|
491
|
+
body_func: Callable[[Mapping[str, str]], Mapping[str, str]],
|
|
492
|
+
path: str,
|
|
493
|
+
expected_body: Mapping[str, str],
|
|
494
|
+
) -> None:
|
|
495
|
+
"""Test Request routing for routes that use path patterns."""
|
|
496
|
+
|
|
497
|
+
class API(APINoAuth):
|
|
498
|
+
PREFIX = prefix_pattern
|
|
499
|
+
|
|
500
|
+
@api.get(path_pattern)
|
|
501
|
+
def route(self) -> list[Response | Effect]:
|
|
502
|
+
return [JSONResponse(body_func(self.request.path_params))]
|
|
503
|
+
|
|
504
|
+
effects = handle_request(API, "GET", path=path)
|
|
505
|
+
body = json_response_body(effects)
|
|
506
|
+
|
|
507
|
+
assert body == expected_body
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
@pytest.mark.parametrize(
|
|
511
|
+
argnames="path_pattern1,path_pattern2,path,expected_body",
|
|
512
|
+
argvalues=[
|
|
513
|
+
("/path/<value>", "/path/test", "/prefix/path/value", {"handler_method": "first"}),
|
|
514
|
+
("/path/<value>", "/path/test", "/prefix/path/test", {"handler_method": "first"}),
|
|
515
|
+
("/path/test", "/path/<value>", "/prefix/path/test", {"handler_method": "first"}),
|
|
516
|
+
("/path/test", "/path/<value>", "/prefix/path/value", {"handler_method": "second"}),
|
|
517
|
+
],
|
|
518
|
+
ids=[
|
|
519
|
+
"pattern registered first, path matches only pattern",
|
|
520
|
+
"pattern registered first, path matches both pattern and fixed",
|
|
521
|
+
"fixed registered first, path matches both pattern and fixed",
|
|
522
|
+
"fixed registered first, path matches only pattern",
|
|
523
|
+
],
|
|
524
|
+
)
|
|
525
|
+
def test_request_routing_path_pattern_multiple_matches(
|
|
526
|
+
path_pattern1: str, path_pattern2: str, path: str, expected_body: Mapping[str, str]
|
|
527
|
+
) -> None:
|
|
528
|
+
"""Test request routing for path patterns where a path matches multiple routes in a handler."""
|
|
529
|
+
|
|
530
|
+
class API(APINoAuth):
|
|
531
|
+
PREFIX = "/prefix"
|
|
532
|
+
|
|
533
|
+
@api.get(path_pattern1)
|
|
534
|
+
def route1(self) -> list[Response | Effect]:
|
|
535
|
+
return [JSONResponse({"handler_method": "first"})]
|
|
536
|
+
|
|
537
|
+
@api.get(path_pattern2)
|
|
538
|
+
def route2(self) -> list[Response | Effect]:
|
|
539
|
+
return [JSONResponse({"handler_method": "second"})]
|
|
540
|
+
|
|
541
|
+
effects = handle_request(API, "GET", path=path)
|
|
542
|
+
body = json_response_body(effects)
|
|
543
|
+
|
|
544
|
+
assert body == expected_body
|
|
545
|
+
|
|
546
|
+
|
|
438
547
|
def test_request_lifecycle() -> None:
|
|
439
548
|
"""Test the request-response lifecycle."""
|
|
440
549
|
|
|
@@ -700,6 +809,17 @@ def test_invalid_path_error() -> None:
|
|
|
700
809
|
return []
|
|
701
810
|
|
|
702
811
|
|
|
812
|
+
def test_invalid_path_pattern_error() -> None:
|
|
813
|
+
"""Test the enforcement of the error that occurs when a route has an invalid path pattern."""
|
|
814
|
+
with pytest.raises(PluginError):
|
|
815
|
+
|
|
816
|
+
class Route(RouteNoAuth):
|
|
817
|
+
PATH = "/path1/<value>/<path2>/<value>"
|
|
818
|
+
|
|
819
|
+
def get(self) -> list[Response | Effect]:
|
|
820
|
+
return []
|
|
821
|
+
|
|
822
|
+
|
|
703
823
|
def test_route_missing_path_error() -> None:
|
|
704
824
|
"""
|
|
705
825
|
Test the enforcement of the error that occurs when a SimpleAPIRoute is missing a PATH value.
|
canvas_sdk/utils/http.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import concurrent
|
|
2
2
|
import functools
|
|
3
3
|
import time
|
|
4
|
+
import urllib.parse
|
|
4
5
|
from collections.abc import Callable, Iterable, Mapping
|
|
5
6
|
from concurrent.futures import ThreadPoolExecutor
|
|
6
7
|
from functools import wraps
|
|
@@ -94,10 +95,35 @@ def batch_patch(
|
|
|
94
95
|
class Http:
|
|
95
96
|
"""A helper class for completing HTTP calls with metrics tracking."""
|
|
96
97
|
|
|
97
|
-
|
|
98
|
+
_MAX_REQUEST_TIMEOUT_SECONDS = 30
|
|
99
|
+
|
|
100
|
+
base_url: str
|
|
101
|
+
session: requests.Session
|
|
102
|
+
|
|
103
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
104
|
+
"""
|
|
105
|
+
Prevent base_url or session from being updated after initialization.
|
|
106
|
+
"""
|
|
107
|
+
if name in ("base_url", "session"):
|
|
108
|
+
raise AttributeError(f"{name} is read-only")
|
|
109
|
+
|
|
110
|
+
super().__setattr__(name, value)
|
|
111
|
+
|
|
112
|
+
def join_url(self, url: str) -> str:
|
|
113
|
+
"""
|
|
114
|
+
Join a URL to the base_url.
|
|
115
|
+
"""
|
|
116
|
+
joined = urllib.parse.urljoin(self.base_url, url)
|
|
117
|
+
|
|
118
|
+
if not joined.startswith(self.base_url):
|
|
119
|
+
raise ValueError("You may not access other URLs using this client.")
|
|
120
|
+
|
|
121
|
+
return joined
|
|
122
|
+
|
|
123
|
+
def __init__(self, base_url: str = "") -> None:
|
|
124
|
+
super().__setattr__("base_url", base_url)
|
|
125
|
+
super().__setattr__("session", requests.Session())
|
|
98
126
|
|
|
99
|
-
def __init__(self) -> None:
|
|
100
|
-
self.session = requests.Session()
|
|
101
127
|
self.statsd_client = statsd.StatsClient()
|
|
102
128
|
|
|
103
129
|
@staticmethod
|
|
@@ -122,7 +148,11 @@ class Http:
|
|
|
122
148
|
"""Sends a GET request."""
|
|
123
149
|
if headers is None:
|
|
124
150
|
headers = {}
|
|
125
|
-
return self.session.get(
|
|
151
|
+
return self.session.get(
|
|
152
|
+
self.join_url(url),
|
|
153
|
+
headers=headers,
|
|
154
|
+
timeout=self._MAX_REQUEST_TIMEOUT_SECONDS,
|
|
155
|
+
)
|
|
126
156
|
|
|
127
157
|
@measure_time
|
|
128
158
|
def post(
|
|
@@ -133,7 +163,13 @@ class Http:
|
|
|
133
163
|
headers: Mapping[str, str | bytes | None] | None = None,
|
|
134
164
|
) -> requests.Response:
|
|
135
165
|
"""Sends a POST request."""
|
|
136
|
-
return self.session.post(
|
|
166
|
+
return self.session.post(
|
|
167
|
+
self.join_url(url),
|
|
168
|
+
json=json,
|
|
169
|
+
data=data,
|
|
170
|
+
headers=headers,
|
|
171
|
+
timeout=self._MAX_REQUEST_TIMEOUT_SECONDS,
|
|
172
|
+
)
|
|
137
173
|
|
|
138
174
|
@measure_time
|
|
139
175
|
def put(
|
|
@@ -144,7 +180,13 @@ class Http:
|
|
|
144
180
|
headers: Mapping[str, str | bytes | None] | None = None,
|
|
145
181
|
) -> requests.Response:
|
|
146
182
|
"""Sends a PUT request."""
|
|
147
|
-
return self.session.put(
|
|
183
|
+
return self.session.put(
|
|
184
|
+
self.join_url(url),
|
|
185
|
+
json=json,
|
|
186
|
+
data=data,
|
|
187
|
+
headers=headers,
|
|
188
|
+
timeout=self._MAX_REQUEST_TIMEOUT_SECONDS,
|
|
189
|
+
)
|
|
148
190
|
|
|
149
191
|
@measure_time
|
|
150
192
|
def patch(
|
|
@@ -155,7 +197,13 @@ class Http:
|
|
|
155
197
|
headers: Mapping[str, str | bytes | None] | None = None,
|
|
156
198
|
) -> requests.Response:
|
|
157
199
|
"""Sends a PATCH request."""
|
|
158
|
-
return self.session.patch(
|
|
200
|
+
return self.session.patch(
|
|
201
|
+
self.join_url(url),
|
|
202
|
+
json=json,
|
|
203
|
+
data=data,
|
|
204
|
+
headers=headers,
|
|
205
|
+
timeout=self._MAX_REQUEST_TIMEOUT_SECONDS,
|
|
206
|
+
)
|
|
159
207
|
|
|
160
208
|
@measure_time
|
|
161
209
|
def batch_requests(
|
|
@@ -170,10 +218,11 @@ class Http:
|
|
|
170
218
|
ordering as the requests.
|
|
171
219
|
"""
|
|
172
220
|
if timeout is None:
|
|
173
|
-
timeout = self.
|
|
174
|
-
elif timeout < 1 or timeout > self.
|
|
221
|
+
timeout = self._MAX_REQUEST_TIMEOUT_SECONDS
|
|
222
|
+
elif timeout < 1 or timeout > self._MAX_REQUEST_TIMEOUT_SECONDS:
|
|
175
223
|
raise ValueError(
|
|
176
|
-
|
|
224
|
+
"Timeout value must be greater than 0 and less than or equal "
|
|
225
|
+
f"to {self._MAX_REQUEST_TIMEOUT_SECONDS} seconds"
|
|
177
226
|
)
|
|
178
227
|
|
|
179
228
|
with ThreadPoolExecutor() as executor:
|
|
@@ -182,3 +231,42 @@ class Http:
|
|
|
182
231
|
concurrent.futures.wait(futures, timeout=timeout)
|
|
183
232
|
|
|
184
233
|
return [future.result() for future in futures]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class OntologiesHttp(Http):
|
|
237
|
+
"""
|
|
238
|
+
An HTTP client for the ontologies service.
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
def __init__(self) -> None:
|
|
242
|
+
super().__init__(base_url="https://ontologies.canvasmedical.com")
|
|
243
|
+
|
|
244
|
+
# import here to avoid making it exportable to module importers
|
|
245
|
+
import os
|
|
246
|
+
|
|
247
|
+
self.session.headers.update({"Authorization": os.getenv("PRE_SHARED_KEY", "")})
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class ScienceHttp(Http):
|
|
251
|
+
"""
|
|
252
|
+
An HTTP client for the ontologies service.
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
def __init__(self) -> None:
|
|
256
|
+
super().__init__(base_url="https://science.canvasmedical.com")
|
|
257
|
+
|
|
258
|
+
# import here to avoid making it exportable to module importers
|
|
259
|
+
import os
|
|
260
|
+
|
|
261
|
+
self.session.headers.update({"Authorization": os.getenv("PRE_SHARED_KEY", "")})
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
__all__ = [
|
|
265
|
+
"Http",
|
|
266
|
+
"OntologiesHttp",
|
|
267
|
+
"ScienceHttp",
|
|
268
|
+
"batch_get",
|
|
269
|
+
"batch_post",
|
|
270
|
+
"batch_put",
|
|
271
|
+
"batch_patch",
|
|
272
|
+
]
|
canvas_sdk/utils/tests.py
CHANGED
|
@@ -9,7 +9,9 @@ def test_http_get(mock_get: MagicMock) -> None:
|
|
|
9
9
|
http = Http()
|
|
10
10
|
http.get("https://www.canvasmedical.com/", headers={"Authorization": "Bearer as;ldkfjdkj"})
|
|
11
11
|
mock_get.assert_called_once_with(
|
|
12
|
-
"https://www.canvasmedical.com/",
|
|
12
|
+
"https://www.canvasmedical.com/",
|
|
13
|
+
headers={"Authorization": "Bearer as;ldkfjdkj"},
|
|
14
|
+
timeout=30,
|
|
13
15
|
)
|
|
14
16
|
|
|
15
17
|
|
|
@@ -28,6 +30,7 @@ def test_http_post(mock_post: MagicMock) -> None:
|
|
|
28
30
|
json={"hey": "hi"},
|
|
29
31
|
data="grant-type=client_credentials",
|
|
30
32
|
headers={"Content-type": "application/json"},
|
|
33
|
+
timeout=30,
|
|
31
34
|
)
|
|
32
35
|
|
|
33
36
|
|
|
@@ -46,6 +49,7 @@ def test_http_put(mock_put: MagicMock) -> None:
|
|
|
46
49
|
json={"hey": "hi"},
|
|
47
50
|
data="grant-type=client_credentials",
|
|
48
51
|
headers={"Content-type": "application/json"},
|
|
52
|
+
timeout=30,
|
|
49
53
|
)
|
|
50
54
|
|
|
51
55
|
|
|
@@ -64,4 +68,5 @@ def test_http_patch(mock_patch: MagicMock) -> None:
|
|
|
64
68
|
json={"hey": "hi"},
|
|
65
69
|
data="grant-type=client_credentials",
|
|
66
70
|
headers={"Content-type": "application/json"},
|
|
71
|
+
timeout=30,
|
|
67
72
|
)
|
canvas_sdk/v1/data/__init__.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from .allergy_intolerance import AllergyIntolerance, AllergyIntoleranceCoding
|
|
2
2
|
from .appointment import Appointment, AppointmentExternalIdentifier
|
|
3
3
|
from .assessment import Assessment
|
|
4
|
+
from .banner_alert import BannerAlert
|
|
4
5
|
from .billing import BillingLineItem, BillingLineItemModifier
|
|
5
6
|
from .care_team import CareTeamMembership, CareTeamRole
|
|
6
7
|
from .command import Command
|
|
@@ -59,6 +60,7 @@ __all__ = [
|
|
|
59
60
|
"AllergyIntolerance",
|
|
60
61
|
"AllergyIntoleranceCoding",
|
|
61
62
|
"Assessment",
|
|
63
|
+
"BannerAlert",
|
|
62
64
|
"BillingLineItem",
|
|
63
65
|
"BillingLineItemModifier",
|
|
64
66
|
"CanvasUser",
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from django.contrib.postgres.fields import ArrayField
|
|
2
|
+
from django.db import models
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class BannerAlert(models.Model):
|
|
6
|
+
"""BannerAlert."""
|
|
7
|
+
|
|
8
|
+
class Meta:
|
|
9
|
+
managed = False
|
|
10
|
+
db_table = "canvas_sdk_data_api_banneralert_001"
|
|
11
|
+
|
|
12
|
+
dbid = models.BigIntegerField(db_column="dbid", primary_key=True)
|
|
13
|
+
created = models.DateTimeField()
|
|
14
|
+
modified = models.DateTimeField()
|
|
15
|
+
patient = models.ForeignKey(
|
|
16
|
+
"v1.Patient",
|
|
17
|
+
on_delete=models.DO_NOTHING,
|
|
18
|
+
related_name="banner_alerts",
|
|
19
|
+
null=True,
|
|
20
|
+
)
|
|
21
|
+
plugin_name = models.CharField()
|
|
22
|
+
key = models.CharField()
|
|
23
|
+
narrative = models.CharField()
|
|
24
|
+
placement = ArrayField(models.CharField())
|
|
25
|
+
intent = models.CharField()
|
|
26
|
+
href = models.CharField()
|
|
27
|
+
status = models.CharField()
|
canvas_sdk/v1/data/lab.py
CHANGED
|
@@ -10,6 +10,7 @@ from canvas_sdk.v1.data.base import (
|
|
|
10
10
|
TimeframeLookupQuerySetMixin,
|
|
11
11
|
ValueSetLookupQuerySet,
|
|
12
12
|
)
|
|
13
|
+
from canvas_sdk.v1.data.staff import Staff
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
class LabReportQuerySet(BaseQuerySet, CommittableQuerySetMixin, ForPatientQuerySetMixin):
|
|
@@ -213,8 +214,7 @@ class LabOrder(models.Model):
|
|
|
213
214
|
courtesy_copy_type = models.CharField(choices=CourtesyCopyType, null=True)
|
|
214
215
|
courtesy_copy_number = models.CharField()
|
|
215
216
|
courtesy_copy_text = models.CharField()
|
|
216
|
-
|
|
217
|
-
# ordering_provider = models.ForeignKey(Staff, on_delete=models.DO_NOTHING, null=True)
|
|
217
|
+
ordering_provider = models.ForeignKey(Staff, on_delete=models.DO_NOTHING, null=True)
|
|
218
218
|
parent_order = models.ForeignKey("LabOrder", on_delete=models.DO_NOTHING, null=True)
|
|
219
219
|
healthgorilla_id = models.CharField()
|
|
220
220
|
manual_processing_status = models.CharField(choices=ManualProcessingStatus)
|
canvas_sdk/v1/data/staff.py
CHANGED
|
@@ -46,10 +46,9 @@ class Staff(models.Model):
|
|
|
46
46
|
cultural_ethnicity_terms = ArrayField(models.CharField())
|
|
47
47
|
last_known_timezone = TimeZoneField(null=True)
|
|
48
48
|
active = models.BooleanField()
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
# )
|
|
49
|
+
primary_practice_location = models.ForeignKey(
|
|
50
|
+
"v1.PracticeLocation", on_delete=models.DO_NOTHING, null=True
|
|
51
|
+
)
|
|
53
52
|
npi_number = models.CharField()
|
|
54
53
|
nadean_number = models.CharField()
|
|
55
54
|
group_npi_number = models.CharField()
|
plugin_runner/authentication.py
CHANGED
|
@@ -25,7 +25,8 @@ def token_for_plugin(
|
|
|
25
25
|
|
|
26
26
|
if not jwt_signing_key:
|
|
27
27
|
log.warning(
|
|
28
|
-
"Using an insecure JWT signing key for GraphQL access. Set the
|
|
28
|
+
"Using an insecure JWT signing key for GraphQL access. Set the "
|
|
29
|
+
"PLUGIN_RUNNER_SIGNING_KEY environment variable to avoid this message."
|
|
29
30
|
)
|
|
30
31
|
|
|
31
32
|
token = encode(
|