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.

Files changed (31) hide show
  1. {canvas-0.27.0.dist-info → canvas-0.29.0.dist-info}/METADATA +1 -1
  2. {canvas-0.27.0.dist-info → canvas-0.29.0.dist-info}/RECORD +31 -28
  3. canvas_cli/apps/auth/tests.py +3 -0
  4. canvas_cli/apps/emit/event_fixtures/SIMPLE_API_REQUEST.ndjson +1 -0
  5. canvas_cli/templates/plugins/application/{{ cookiecutter.__project_slug }}/CANVAS_MANIFEST.json +6 -4
  6. canvas_cli/utils/validators/manifest_schema.py +3 -0
  7. canvas_generated/messages/effects_pb2.py +2 -2
  8. canvas_generated/messages/effects_pb2.pyi +4 -0
  9. canvas_generated/messages/events_pb2.py +2 -2
  10. canvas_generated/messages/events_pb2.pyi +4 -0
  11. canvas_sdk/commands/commands/perform.py +1 -1
  12. canvas_sdk/effects/patient_portal/application_configuration.py +22 -0
  13. canvas_sdk/effects/show_button.py +2 -1
  14. canvas_sdk/effects/simple_api.py +2 -2
  15. canvas_sdk/handlers/action_button.py +6 -1
  16. canvas_sdk/handlers/simple_api/api.py +37 -20
  17. canvas_sdk/tests/handlers/test_simple_api.py +122 -2
  18. canvas_sdk/utils/http.py +98 -10
  19. canvas_sdk/utils/tests.py +6 -1
  20. canvas_sdk/v1/data/__init__.py +2 -0
  21. canvas_sdk/v1/data/banner_alert.py +27 -0
  22. canvas_sdk/v1/data/lab.py +2 -2
  23. canvas_sdk/v1/data/staff.py +3 -4
  24. plugin_runner/authentication.py +2 -1
  25. plugin_runner/plugin_runner.py +6 -3
  26. plugin_runner/sandbox.py +2 -2
  27. protobufs/canvas_generated/messages/effects.proto +2 -0
  28. protobufs/canvas_generated/messages/events.proto +2 -0
  29. settings.py +2 -0
  30. {canvas-0.27.0.dist-info → canvas-0.29.0.dist-info}/WHEEL +0 -0
  31. {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
@@ -8,5 +8,5 @@ class PerformCommand(BaseCommand):
8
8
  key = "perform"
9
9
  commit_required_fields = ("cpt_code",)
10
10
 
11
- cpt_code: str
11
+ cpt_code: str | None = None
12
12
  notes: str | None = None
@@ -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]:
@@ -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 = dict[str, "JSON"] | list["JSON"] | int | float | str | bool | None
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 [ShowButtonEffect(key=self.BUTTON_KEY, title=self.BUTTON_TITLE).apply()]
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[tuple[str, str], RouteHandler]]
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
- cls._ROUTES[(method, path)] = attr
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; please specify the type of "
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 = handler(self)
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 (self.request.method, self.request.path) in self._ROUTES
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
- _MAX_WORKER_TIMEOUT_SECONDS = 30
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(url, headers=headers)
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(url, json=json, data=data, headers=headers)
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(url, json=json, data=data, headers=headers)
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(url, json=json, data=data, headers=headers)
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._MAX_WORKER_TIMEOUT_SECONDS
174
- elif timeout < 1 or timeout > self._MAX_WORKER_TIMEOUT_SECONDS:
221
+ timeout = self._MAX_REQUEST_TIMEOUT_SECONDS
222
+ elif timeout < 1 or timeout > self._MAX_REQUEST_TIMEOUT_SECONDS:
175
223
  raise ValueError(
176
- f"Timeout value must be greater than 0 and less than or equal to {self._MAX_WORKER_TIMEOUT_SECONDS} seconds"
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/", headers={"Authorization": "Bearer as;ldkfjdkj"}
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
  )
@@ -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
- # TODO - uncomment when Staff model is added
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)
@@ -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
- # TODO - uncomment when PracticeLocation field is developed
50
- # primary_practice_location = models.ForeignKey(
51
- # 'v1.PracticeLocation', on_delete=models.DO_NOTHING, null=True
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()
@@ -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 PLUGIN_RUNNER_SIGNING_KEY environment variable to avoid this message."
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(