canvas 0.34.1__py3-none-any.whl → 0.35.1__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.34.1.dist-info → canvas-0.35.1.dist-info}/METADATA +2 -2
- {canvas-0.34.1.dist-info → canvas-0.35.1.dist-info}/RECORD +59 -51
- canvas_generated/messages/effects_pb2.py +4 -4
- canvas_generated/messages/effects_pb2.pyi +22 -2
- canvas_generated/messages/events_pb2.py +2 -2
- canvas_generated/messages/events_pb2.pyi +30 -0
- canvas_sdk/base.py +56 -0
- canvas_sdk/commands/base.py +22 -46
- canvas_sdk/commands/commands/adjust_prescription.py +0 -10
- canvas_sdk/commands/commands/allergy.py +0 -1
- canvas_sdk/commands/commands/assess.py +2 -2
- canvas_sdk/commands/commands/change_medication.py +58 -0
- canvas_sdk/commands/commands/close_goal.py +0 -1
- canvas_sdk/commands/commands/diagnose.py +0 -1
- canvas_sdk/commands/commands/exam.py +0 -1
- canvas_sdk/commands/commands/family_history.py +0 -1
- canvas_sdk/commands/commands/follow_up.py +4 -2
- canvas_sdk/commands/commands/goal.py +8 -7
- canvas_sdk/commands/commands/history_present_illness.py +0 -1
- canvas_sdk/commands/commands/imaging_order.py +9 -8
- canvas_sdk/commands/commands/instruct.py +2 -2
- canvas_sdk/commands/commands/lab_order.py +10 -9
- canvas_sdk/commands/commands/medical_history.py +0 -1
- canvas_sdk/commands/commands/medication_statement.py +0 -1
- canvas_sdk/commands/commands/past_surgical_history.py +0 -1
- canvas_sdk/commands/commands/perform.py +3 -2
- canvas_sdk/commands/commands/plan.py +0 -1
- canvas_sdk/commands/commands/prescribe.py +0 -9
- canvas_sdk/commands/commands/refer.py +10 -10
- canvas_sdk/commands/commands/refill.py +0 -9
- canvas_sdk/commands/commands/remove_allergy.py +0 -1
- canvas_sdk/commands/commands/resolve_condition.py +3 -2
- canvas_sdk/commands/commands/review_of_systems.py +0 -1
- canvas_sdk/commands/commands/stop_medication.py +0 -1
- canvas_sdk/commands/commands/structured_assessment.py +0 -1
- canvas_sdk/commands/commands/task.py +0 -4
- canvas_sdk/commands/commands/update_diagnosis.py +8 -6
- canvas_sdk/commands/commands/update_goal.py +0 -1
- canvas_sdk/commands/commands/vitals.py +0 -1
- canvas_sdk/effects/note/__init__.py +10 -0
- canvas_sdk/effects/note/appointment.py +148 -0
- canvas_sdk/effects/note/base.py +129 -0
- canvas_sdk/effects/note/note.py +79 -0
- canvas_sdk/effects/patient/__init__.py +3 -0
- canvas_sdk/effects/patient/base.py +123 -0
- canvas_sdk/utils/http.py +7 -26
- canvas_sdk/utils/metrics.py +192 -0
- canvas_sdk/utils/plugins.py +24 -0
- canvas_sdk/v1/data/__init__.py +4 -0
- canvas_sdk/v1/data/message.py +82 -0
- plugin_runner/load_all_plugins.py +0 -3
- plugin_runner/plugin_runner.py +159 -198
- plugin_runner/sandbox.py +3 -0
- protobufs/canvas_generated/messages/effects.proto +13 -0
- protobufs/canvas_generated/messages/events.proto +16 -0
- pubsub/pubsub.py +10 -1
- settings.py +8 -0
- canvas_sdk/utils/stats.py +0 -74
- {canvas-0.34.1.dist-info → canvas-0.35.1.dist-info}/WHEEL +0 -0
- {canvas-0.34.1.dist-info → canvas-0.35.1.dist-info}/entry_points.txt +0 -0
plugin_runner/plugin_runner.py
CHANGED
|
@@ -1,24 +1,28 @@
|
|
|
1
|
-
import asyncio
|
|
2
1
|
import json
|
|
3
2
|
import os
|
|
4
3
|
import pathlib
|
|
5
4
|
import pickle
|
|
6
5
|
import pkgutil
|
|
7
6
|
import sys
|
|
8
|
-
import
|
|
7
|
+
import threading
|
|
9
8
|
import traceback
|
|
10
9
|
from collections import defaultdict
|
|
11
|
-
from collections.abc import
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
12
12
|
from http import HTTPStatus
|
|
13
|
+
from time import sleep
|
|
13
14
|
from typing import Any, TypedDict
|
|
14
15
|
|
|
15
16
|
import grpc
|
|
16
|
-
import redis
|
|
17
|
+
import redis
|
|
17
18
|
import sentry_sdk
|
|
18
|
-
from
|
|
19
|
-
from
|
|
19
|
+
from django.core.signals import request_finished, request_started
|
|
20
|
+
from redis.backoff import ExponentialBackoff
|
|
21
|
+
from redis.exceptions import ConnectionError, TimeoutError
|
|
22
|
+
from redis.retry import Retry
|
|
20
23
|
from sentry_sdk.integrations.logging import ignore_logger
|
|
21
24
|
|
|
25
|
+
import settings
|
|
22
26
|
from canvas_generated.messages.effects_pb2 import EffectType
|
|
23
27
|
from canvas_generated.messages.plugins_pb2 import ReloadPluginsRequest, ReloadPluginsResponse
|
|
24
28
|
from canvas_generated.services.plugin_runner_pb2_grpc import (
|
|
@@ -29,7 +33,8 @@ from canvas_sdk.effects import Effect
|
|
|
29
33
|
from canvas_sdk.effects.simple_api import Response
|
|
30
34
|
from canvas_sdk.events import Event, EventRequest, EventResponse, EventType
|
|
31
35
|
from canvas_sdk.protocols import ClinicalQualityMeasure
|
|
32
|
-
from canvas_sdk.utils
|
|
36
|
+
from canvas_sdk.utils import metrics
|
|
37
|
+
from canvas_sdk.utils.metrics import measured
|
|
33
38
|
from logger import log
|
|
34
39
|
from plugin_runner.authentication import token_for_plugin
|
|
35
40
|
from plugin_runner.installation import install_plugins
|
|
@@ -39,7 +44,6 @@ from settings import (
|
|
|
39
44
|
CUSTOMER_IDENTIFIER,
|
|
40
45
|
ENV,
|
|
41
46
|
IS_PRODUCTION_CUSTOMER,
|
|
42
|
-
IS_TESTING,
|
|
43
47
|
MANIFEST_FILE_NAME,
|
|
44
48
|
PLUGIN_DIRECTORY,
|
|
45
49
|
REDIS_ENDPOINT,
|
|
@@ -149,164 +153,147 @@ class PluginManifest(TypedDict):
|
|
|
149
153
|
readme: str
|
|
150
154
|
|
|
151
155
|
|
|
152
|
-
@sync_to_async
|
|
153
|
-
def reconnect_if_needed() -> None:
|
|
154
|
-
"""
|
|
155
|
-
Reconnect to the database if the connection has been closed.
|
|
156
|
-
|
|
157
|
-
NOTE: Django database functions are not async-safe and must be called
|
|
158
|
-
via e.g. sync_to_async. They will silently fail and block the handler
|
|
159
|
-
if called in an async context directly.
|
|
160
|
-
"""
|
|
161
|
-
if IS_TESTING:
|
|
162
|
-
return
|
|
163
|
-
|
|
164
|
-
connection = connections["default"]
|
|
165
|
-
|
|
166
|
-
if not connection.is_usable():
|
|
167
|
-
log.debug("Connection was unusable, reconnecting...")
|
|
168
|
-
connection.connect()
|
|
169
|
-
|
|
170
|
-
|
|
171
156
|
class PluginRunner(PluginRunnerServicer):
|
|
172
157
|
"""This process runs provided plugins that register interest in incoming events."""
|
|
173
158
|
|
|
174
159
|
sandbox: Sandbox
|
|
175
160
|
|
|
176
|
-
|
|
177
|
-
self, request: EventRequest, context: Any
|
|
178
|
-
) -> AsyncGenerator[EventResponse, None]:
|
|
161
|
+
def HandleEvent(self, request: EventRequest, context: Any) -> Iterable[EventResponse]:
|
|
179
162
|
"""This is invoked when an event comes in."""
|
|
180
|
-
event_start_time = time.time()
|
|
181
163
|
event = Event(request)
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
164
|
+
with metrics.measure(
|
|
165
|
+
metrics.get_qualified_name(self.HandleEvent), extra_tags={"event": event.name}
|
|
166
|
+
):
|
|
167
|
+
event_type = event.type
|
|
168
|
+
event_name = event.name
|
|
169
|
+
relevant_plugins = EVENT_HANDLER_MAP[event_name]
|
|
170
|
+
relevant_plugin_handlers = []
|
|
171
|
+
|
|
172
|
+
log.debug(f"Processing {relevant_plugins} for {event_name}")
|
|
173
|
+
sentry_sdk.set_tag("event-name", event_name)
|
|
174
|
+
|
|
175
|
+
if relevant_plugins:
|
|
176
|
+
# Send the Django request_started signal
|
|
177
|
+
request_started.send(sender=self.__class__)
|
|
178
|
+
|
|
179
|
+
if event_type in [EventType.PLUGIN_CREATED, EventType.PLUGIN_UPDATED]:
|
|
180
|
+
plugin_name = event.target.id
|
|
181
|
+
# filter only for the plugin(s) that were created/updated
|
|
182
|
+
relevant_plugins = [p for p in relevant_plugins if p.startswith(f"{plugin_name}:")]
|
|
183
|
+
elif event_type in {EventType.SIMPLE_API_AUTHENTICATE, EventType.SIMPLE_API_REQUEST}:
|
|
184
|
+
# The target plugin's name will be part of the home-app URL path, so other plugins that
|
|
185
|
+
# respond to SimpleAPI request events are not relevant
|
|
186
|
+
plugin_name = event.context["plugin_name"]
|
|
187
|
+
relevant_plugins = [p for p in relevant_plugins if p.startswith(f"{plugin_name}:")]
|
|
188
|
+
|
|
189
|
+
effect_list = []
|
|
190
|
+
|
|
191
|
+
for plugin_name in relevant_plugins:
|
|
192
|
+
log.debug(f"Processing {plugin_name}")
|
|
193
|
+
sentry_sdk.set_tag("plugin-name", plugin_name)
|
|
194
|
+
|
|
195
|
+
plugin = LOADED_PLUGINS[plugin_name]
|
|
196
|
+
handler_class = plugin["class"]
|
|
197
|
+
base_plugin_name = plugin_name.split(":")[0]
|
|
198
|
+
|
|
199
|
+
secrets = plugin.get("secrets", {})
|
|
200
|
+
|
|
201
|
+
secrets.update(
|
|
202
|
+
{"graphql_jwt": token_for_plugin(plugin_name=plugin_name, audience="home")}
|
|
203
|
+
)
|
|
212
204
|
|
|
213
|
-
|
|
205
|
+
try:
|
|
206
|
+
handler = handler_class(event, secrets, ENVIRONMENT)
|
|
214
207
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
208
|
+
if not handler.accept_event():
|
|
209
|
+
continue
|
|
210
|
+
relevant_plugin_handlers.append(handler_class)
|
|
218
211
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
212
|
+
classname = (
|
|
213
|
+
handler.__class__.__name__
|
|
214
|
+
if isinstance(handler, ClinicalQualityMeasure)
|
|
215
|
+
else None
|
|
216
|
+
)
|
|
217
|
+
handler_name = metrics.get_qualified_name(handler.compute)
|
|
218
|
+
with metrics.measure(
|
|
219
|
+
name=handler_name,
|
|
220
|
+
extra_tags={
|
|
221
|
+
"plugin": base_plugin_name,
|
|
222
|
+
"event": event_name,
|
|
223
|
+
},
|
|
224
|
+
):
|
|
225
|
+
_effects = handler.compute()
|
|
226
|
+
effects = [
|
|
227
|
+
Effect(
|
|
228
|
+
type=effect.type,
|
|
229
|
+
payload=effect.payload,
|
|
230
|
+
plugin_name=base_plugin_name,
|
|
231
|
+
classname=classname,
|
|
232
|
+
handler_name=handler_name,
|
|
233
|
+
)
|
|
234
|
+
for effect in _effects
|
|
235
|
+
]
|
|
236
|
+
|
|
237
|
+
effects = validate_effects(effects)
|
|
238
|
+
|
|
239
|
+
apply_effects_to_context(effects, event=event)
|
|
240
|
+
|
|
241
|
+
log.info(f"{plugin_name}.compute() completed.")
|
|
242
|
+
|
|
243
|
+
except Exception as e:
|
|
244
|
+
log.error(f"Encountered exception in plugin {plugin_name}:")
|
|
245
|
+
|
|
246
|
+
for error_line_with_newlines in traceback.format_exception(e):
|
|
247
|
+
for error_line in error_line_with_newlines.split("\n"):
|
|
248
|
+
log.error(error_line)
|
|
249
|
+
|
|
250
|
+
sentry_sdk.capture_exception(e)
|
|
223
251
|
continue
|
|
224
|
-
relevant_plugin_handlers.append(handler_class)
|
|
225
252
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
253
|
+
effect_list += effects
|
|
254
|
+
|
|
255
|
+
sentry_sdk.set_tag("plugin-name", None)
|
|
256
|
+
|
|
257
|
+
# Special handling for SimpleAPI requests: if there were no relevant handlers (as determined
|
|
258
|
+
# by calling ignore_event on handlers), then set the effects list to be a single 404 Not
|
|
259
|
+
# Found response effect. If multiple handlers were able to respond, log an error and set the
|
|
260
|
+
# effects list to be a single 500 Internal Server Error response effect.
|
|
261
|
+
if event.type in {EventType.SIMPLE_API_AUTHENTICATE, EventType.SIMPLE_API_REQUEST}:
|
|
262
|
+
if len(relevant_plugin_handlers) == 0:
|
|
263
|
+
effect_list = [Response(status_code=HTTPStatus.NOT_FOUND).apply()]
|
|
264
|
+
elif len(relevant_plugin_handlers) > 1:
|
|
265
|
+
log.error(
|
|
266
|
+
f"Multiple handlers responded to {EventType.Name(EventType.SIMPLE_API_REQUEST)}"
|
|
267
|
+
f" {event.context['path']}"
|
|
240
268
|
)
|
|
241
|
-
|
|
242
|
-
]
|
|
243
|
-
|
|
244
|
-
effects = validate_effects(effects)
|
|
245
|
-
|
|
246
|
-
apply_effects_to_context(effects, event=event)
|
|
247
|
-
|
|
248
|
-
compute_duration = get_duration_ms(compute_start_time)
|
|
249
|
-
|
|
250
|
-
log.info(f"{plugin_name}.compute() completed ({compute_duration} ms)")
|
|
251
|
-
statsd_client.timing(
|
|
252
|
-
"plugins.protocol_duration_ms",
|
|
253
|
-
delta=compute_duration,
|
|
254
|
-
tags={"plugin": plugin_name},
|
|
255
|
-
)
|
|
256
|
-
except Exception as e:
|
|
257
|
-
log.error(f"Encountered exception in plugin {plugin_name}:")
|
|
258
|
-
|
|
259
|
-
for error_line_with_newlines in traceback.format_exception(e):
|
|
260
|
-
for error_line in error_line_with_newlines.split("\n"):
|
|
261
|
-
log.error(error_line)
|
|
262
|
-
|
|
263
|
-
sentry_sdk.capture_exception(e)
|
|
269
|
+
effect_list = [Response(status_code=HTTPStatus.INTERNAL_SERVER_ERROR).apply()]
|
|
264
270
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
sentry_sdk.set_tag("plugin-name", None)
|
|
270
|
-
|
|
271
|
-
# Special handling for SimpleAPI requests: if there were no relevant handlers (as determined
|
|
272
|
-
# by calling ignore_event on handlers), then set the effects list to be a single 404 Not
|
|
273
|
-
# Found response effect. If multiple handlers were able to respond, log an error and set the
|
|
274
|
-
# effects list to be a single 500 Internal Server Error response effect.
|
|
275
|
-
if event.type in {EventType.SIMPLE_API_AUTHENTICATE, EventType.SIMPLE_API_REQUEST}:
|
|
276
|
-
if len(relevant_plugin_handlers) == 0:
|
|
277
|
-
effect_list = [Response(status_code=HTTPStatus.NOT_FOUND).apply()]
|
|
278
|
-
elif len(relevant_plugin_handlers) > 1:
|
|
279
|
-
log.error(
|
|
280
|
-
f"Multiple handlers responded to {EventType.Name(EventType.SIMPLE_API_REQUEST)}"
|
|
281
|
-
f" {event.context['path']}"
|
|
282
|
-
)
|
|
283
|
-
effect_list = [Response(status_code=HTTPStatus.INTERNAL_SERVER_ERROR).apply()]
|
|
284
|
-
|
|
285
|
-
event_duration = get_duration_ms(event_start_time)
|
|
271
|
+
# Don't log anything if a plugin handler didn't actually run.
|
|
272
|
+
if relevant_plugins:
|
|
273
|
+
# Send the Django request_finished signal
|
|
274
|
+
request_finished.send(sender=self.__class__)
|
|
286
275
|
|
|
287
|
-
|
|
288
|
-
if relevant_plugins:
|
|
289
|
-
log.info(f"Responded to Event {event_name} ({event_duration} ms)")
|
|
290
|
-
statsd_client.timing(
|
|
291
|
-
"plugins.event_duration_ms", delta=event_duration, tags={"event": event_name}
|
|
292
|
-
)
|
|
276
|
+
log.info(f"Responded to Event {event_name}.")
|
|
293
277
|
|
|
294
|
-
|
|
278
|
+
yield EventResponse(success=True, effects=effect_list)
|
|
295
279
|
|
|
296
|
-
|
|
280
|
+
def ReloadPlugins(
|
|
297
281
|
self, request: ReloadPluginsRequest, context: Any
|
|
298
|
-
) ->
|
|
282
|
+
) -> Iterable[ReloadPluginsResponse]:
|
|
299
283
|
"""This is invoked when we need to reload plugins."""
|
|
300
284
|
log.info("Reloading plugins...")
|
|
301
285
|
try:
|
|
302
|
-
|
|
286
|
+
publish_message(message={"action": "reload"})
|
|
303
287
|
except ImportError:
|
|
304
288
|
yield ReloadPluginsResponse(success=False)
|
|
305
289
|
else:
|
|
306
290
|
yield ReloadPluginsResponse(success=True)
|
|
307
291
|
|
|
308
292
|
|
|
309
|
-
|
|
293
|
+
STOP_SYNCHRONIZER = threading.Event()
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def synchronize_plugins(run_once: bool = False) -> None:
|
|
310
297
|
"""
|
|
311
298
|
Listen for messages on the pubsub channel that will indicate it is
|
|
312
299
|
necessary to reinstall and reload plugins.
|
|
@@ -315,16 +302,10 @@ async def synchronize_plugins(run_once: bool = False) -> None:
|
|
|
315
302
|
|
|
316
303
|
_, pubsub = get_client()
|
|
317
304
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
while True:
|
|
321
|
-
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=5.0)
|
|
305
|
+
pubsub.psubscribe(CHANNEL_NAME)
|
|
322
306
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
if not pubsub.connection.is_connected: # type: ignore
|
|
326
|
-
log.info("synchronize_plugins: reconnecting to Redis")
|
|
327
|
-
await pubsub.connection.connect() # type: ignore
|
|
307
|
+
while not STOP_SYNCHRONIZER.is_set():
|
|
308
|
+
message = pubsub.get_message(ignore_subscribe_messages=True, timeout=5.0)
|
|
328
309
|
|
|
329
310
|
if message is None:
|
|
330
311
|
continue
|
|
@@ -360,21 +341,21 @@ async def synchronize_plugins(run_once: bool = False) -> None:
|
|
|
360
341
|
break
|
|
361
342
|
|
|
362
343
|
|
|
363
|
-
|
|
344
|
+
def synchronize_plugins_and_report_errors() -> None:
|
|
364
345
|
"""
|
|
365
346
|
Run synchronize_plugins() in perpetuity and report any encountered errors.
|
|
366
347
|
"""
|
|
367
348
|
log.info("synchronize_plugins: starting loop...")
|
|
368
349
|
|
|
369
|
-
while
|
|
350
|
+
while not STOP_SYNCHRONIZER.is_set():
|
|
370
351
|
try:
|
|
371
|
-
|
|
352
|
+
synchronize_plugins()
|
|
372
353
|
except Exception as e:
|
|
373
354
|
log.error(f"synchronize_plugins: error: {e}")
|
|
374
355
|
sentry_sdk.capture_exception(e)
|
|
375
356
|
|
|
376
357
|
# don't crush redis if we're retrying in a tight loop
|
|
377
|
-
|
|
358
|
+
sleep(0.5)
|
|
378
359
|
|
|
379
360
|
|
|
380
361
|
def validate_effects(effects: list[Effect]) -> list[Effect]:
|
|
@@ -437,17 +418,22 @@ def find_modules(base_path: pathlib.Path, prefix: str | None = None) -> list[str
|
|
|
437
418
|
return modules
|
|
438
419
|
|
|
439
420
|
|
|
440
|
-
|
|
421
|
+
def publish_message(message: dict) -> None:
|
|
441
422
|
"""Publish a message to the pubsub channel."""
|
|
442
423
|
log.info(f'Publishing message to pubsub channel "{CHANNEL_NAME}"')
|
|
443
424
|
client, _ = get_client()
|
|
444
425
|
|
|
445
|
-
|
|
426
|
+
client.publish(CHANNEL_NAME, pickle.dumps(message))
|
|
446
427
|
|
|
447
428
|
|
|
448
429
|
def get_client() -> tuple[redis.Redis, redis.client.PubSub]:
|
|
449
|
-
"""Return
|
|
450
|
-
client = redis.
|
|
430
|
+
"""Return a Redis client and pubsub object."""
|
|
431
|
+
client = redis.from_url(
|
|
432
|
+
REDIS_ENDPOINT,
|
|
433
|
+
retry=Retry(backoff=ExponentialBackoff(), retries=10),
|
|
434
|
+
retry_on_error=[ConnectionError, TimeoutError, ConnectionResetError],
|
|
435
|
+
health_check_interval=1,
|
|
436
|
+
)
|
|
451
437
|
pubsub = client.pubsub()
|
|
452
438
|
|
|
453
439
|
return client, pubsub
|
|
@@ -560,6 +546,7 @@ def refresh_event_type_map() -> None:
|
|
|
560
546
|
log.warning(f"Unknown RESPONDS_TO type: {type(responds_to)}")
|
|
561
547
|
|
|
562
548
|
|
|
549
|
+
@measured
|
|
563
550
|
def load_plugins(specified_plugin_paths: list[str] | None = None) -> None:
|
|
564
551
|
"""Load the plugins."""
|
|
565
552
|
# first mark each plugin as inactive since we want to remove it from
|
|
@@ -596,67 +583,41 @@ def load_plugins(specified_plugin_paths: list[str] | None = None) -> None:
|
|
|
596
583
|
|
|
597
584
|
refresh_event_type_map()
|
|
598
585
|
|
|
599
|
-
log_nr_event_handlers()
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
def log_nr_event_handlers() -> None:
|
|
603
|
-
"""Log the number of event handlers for each event."""
|
|
604
|
-
for key in EventType.keys(): # noqa: SIM118
|
|
605
|
-
value = len(EVENT_HANDLER_MAP[key]) if key in EVENT_HANDLER_MAP else 0
|
|
606
|
-
statsd_client.timing("plugins.event_nr_handlers", value, tags={"event": key})
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
_cleanup_coroutines = []
|
|
610
|
-
|
|
611
586
|
|
|
612
|
-
|
|
613
|
-
|
|
587
|
+
# NOTE: specified_plugin_paths powers the `canvas run-plugins` command
|
|
588
|
+
def main(specified_plugin_paths: list[str] | None = None) -> None:
|
|
589
|
+
"""Run the server and the synchronize_plugins loop."""
|
|
614
590
|
port = "50051"
|
|
615
591
|
|
|
616
|
-
|
|
592
|
+
executor = ThreadPoolExecutor(max_workers=settings.PLUGIN_RUNNER_MAX_WORKERS)
|
|
593
|
+
server = grpc.server(thread_pool=executor)
|
|
617
594
|
server.add_insecure_port("127.0.0.1:" + port)
|
|
618
595
|
|
|
619
596
|
add_PluginRunnerServicer_to_server(PluginRunner(), server)
|
|
620
597
|
|
|
621
598
|
log.info(f"Starting server, listening on port {port}")
|
|
622
599
|
|
|
623
|
-
# Only install plugins if the plugin runner was not started
|
|
600
|
+
# Only install plugins and start the synchronizer thread if the plugin runner was not started
|
|
601
|
+
# from the CLI
|
|
602
|
+
synchronizer_thread = threading.Thread(target=synchronize_plugins_and_report_errors)
|
|
624
603
|
if specified_plugin_paths is None:
|
|
625
604
|
install_plugins()
|
|
605
|
+
STOP_SYNCHRONIZER.clear()
|
|
606
|
+
synchronizer_thread.start()
|
|
626
607
|
|
|
627
608
|
load_plugins(specified_plugin_paths)
|
|
628
609
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
async def server_graceful_shutdown() -> None:
|
|
632
|
-
log.info("Starting graceful shutdown...")
|
|
633
|
-
await server.stop(5)
|
|
634
|
-
|
|
635
|
-
_cleanup_coroutines.append(server_graceful_shutdown())
|
|
636
|
-
|
|
637
|
-
await server.wait_for_termination()
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
# NOTE: specified_plugin_paths powers the `canvas run-plugins` command
|
|
641
|
-
def main(specified_plugin_paths: list[str] | None = None) -> None:
|
|
642
|
-
"""Run the server and the synchronize_plugins loop."""
|
|
643
|
-
loop = asyncio.new_event_loop()
|
|
644
|
-
|
|
645
|
-
asyncio.set_event_loop(loop)
|
|
610
|
+
server.start()
|
|
646
611
|
|
|
647
612
|
try:
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
# Only start the synchronizer if the plugin runner was not started from the CLI
|
|
651
|
-
if specified_plugin_paths is None:
|
|
652
|
-
coroutines.append(synchronize_plugins_and_report_errors())
|
|
653
|
-
|
|
654
|
-
loop.run_until_complete(asyncio.gather(*coroutines))
|
|
613
|
+
server.wait_for_termination()
|
|
655
614
|
except KeyboardInterrupt:
|
|
656
615
|
pass
|
|
657
616
|
finally:
|
|
658
|
-
|
|
659
|
-
|
|
617
|
+
executor.shutdown(wait=True, cancel_futures=True)
|
|
618
|
+
if synchronizer_thread.is_alive():
|
|
619
|
+
STOP_SYNCHRONIZER.set()
|
|
620
|
+
synchronizer_thread.join()
|
|
660
621
|
|
|
661
622
|
|
|
662
623
|
if __name__ == "__main__":
|
plugin_runner/sandbox.py
CHANGED
|
@@ -169,6 +169,12 @@ enum EffectType {
|
|
|
169
169
|
COMMIT_REFER_COMMAND = 142;
|
|
170
170
|
ENTER_IN_ERROR_REFER_COMMAND = 143;
|
|
171
171
|
|
|
172
|
+
ORIGINATE_CHANGE_MEDICATION_COMMAND = 150;
|
|
173
|
+
EDIT_CHANGE_MEDICATION_COMMAND = 151;
|
|
174
|
+
DELETE_CHANGE_MEDICATION_COMMAND = 152;
|
|
175
|
+
COMMIT_CHANGE_MEDICATION_COMMAND = 153;
|
|
176
|
+
ENTER_IN_ERROR_CHANGE_MEDICATION_COMMAND = 154;
|
|
177
|
+
|
|
172
178
|
CREATE_QUESTIONNAIRE_RESULT = 138;
|
|
173
179
|
|
|
174
180
|
ANNOTATE_PATIENT_CHART_CONDITION_RESULTS = 200;
|
|
@@ -256,6 +262,12 @@ enum EffectType {
|
|
|
256
262
|
SIMPLE_API_RESPONSE = 4000;
|
|
257
263
|
|
|
258
264
|
UPDATE_USER = 5000;
|
|
265
|
+
|
|
266
|
+
CREATE_NOTE = 6000;
|
|
267
|
+
CREATE_APPOINTMENT = 6001;
|
|
268
|
+
CREATE_SCHEDULE_EVENT = 6002;
|
|
269
|
+
|
|
270
|
+
CREATE_PATIENT = 6003;
|
|
259
271
|
}
|
|
260
272
|
|
|
261
273
|
message Effect {
|
|
@@ -263,6 +275,7 @@ message Effect {
|
|
|
263
275
|
string payload = 2;
|
|
264
276
|
string plugin_name = 3;
|
|
265
277
|
string classname = 4;
|
|
278
|
+
string handler_name = 5;
|
|
266
279
|
//Oneof effect_payload {
|
|
267
280
|
// ...
|
|
268
281
|
//}
|
|
@@ -1028,6 +1028,22 @@ enum EventType {
|
|
|
1028
1028
|
DEFER_CODING_GAP_COMMAND__POST_EXECUTE_ACTION = 60011;
|
|
1029
1029
|
DEFER_CODING_GAP_COMMAND__POST_INSERTED_INTO_NOTE = 60012;
|
|
1030
1030
|
|
|
1031
|
+
CHANGE_MEDICATION_COMMAND__PRE_ORIGINATE = 61000;
|
|
1032
|
+
CHANGE_MEDICATION_COMMAND__POST_ORIGINATE = 61001;
|
|
1033
|
+
CHANGE_MEDICATION_COMMAND__PRE_UPDATE = 61002;
|
|
1034
|
+
CHANGE_MEDICATION_COMMAND__POST_UPDATE = 61003;
|
|
1035
|
+
CHANGE_MEDICATION_COMMAND__PRE_COMMIT = 61004;
|
|
1036
|
+
CHANGE_MEDICATION_COMMAND__POST_COMMIT = 61005;
|
|
1037
|
+
CHANGE_MEDICATION_COMMAND__PRE_DELETE = 61006;
|
|
1038
|
+
CHANGE_MEDICATION_COMMAND__POST_DELETE = 61007;
|
|
1039
|
+
CHANGE_MEDICATION_COMMAND__PRE_ENTER_IN_ERROR = 61008;
|
|
1040
|
+
CHANGE_MEDICATION_COMMAND__POST_ENTER_IN_ERROR = 61009;
|
|
1041
|
+
CHANGE_MEDICATION_COMMAND__PRE_EXECUTE_ACTION = 61010;
|
|
1042
|
+
CHANGE_MEDICATION_COMMAND__POST_EXECUTE_ACTION = 61011;
|
|
1043
|
+
CHANGE_MEDICATION_COMMAND__POST_INSERTED_INTO_NOTE = 61012;
|
|
1044
|
+
CHANGE_MEDICATION__MEDICATION__PRE_SEARCH = 61013;
|
|
1045
|
+
CHANGE_MEDICATION__MEDICATION__POST_SEARCH = 61014;
|
|
1046
|
+
|
|
1031
1047
|
SHOW_NOTE_HEADER_BUTTON = 70000;
|
|
1032
1048
|
SHOW_NOTE_FOOTER_BUTTON = 70001;
|
|
1033
1049
|
ACTION_BUTTON_CLICKED = 70002;
|
pubsub/pubsub.py
CHANGED
|
@@ -2,6 +2,9 @@ import os
|
|
|
2
2
|
from typing import Any
|
|
3
3
|
|
|
4
4
|
import redis
|
|
5
|
+
from redis.backoff import ExponentialBackoff
|
|
6
|
+
from redis.exceptions import ConnectionError, TimeoutError
|
|
7
|
+
from redis.retry import Retry
|
|
5
8
|
|
|
6
9
|
REDIS_ENDPOINT = os.getenv("REDIS_ENDPOINT")
|
|
7
10
|
CUSTOMER_IDENTIFIER = os.getenv("CUSTOMER_IDENTIFIER")
|
|
@@ -24,7 +27,13 @@ class PubSubBase:
|
|
|
24
27
|
|
|
25
28
|
def _create_client(self) -> redis.Redis | None:
|
|
26
29
|
if self.redis_endpoint and self.channel:
|
|
27
|
-
return redis.Redis.from_url(
|
|
30
|
+
return redis.Redis.from_url(
|
|
31
|
+
self.redis_endpoint,
|
|
32
|
+
decode_responses=True,
|
|
33
|
+
retry=Retry(backoff=ExponentialBackoff(), retries=10),
|
|
34
|
+
retry_on_error=[ConnectionError, TimeoutError, ConnectionResetError],
|
|
35
|
+
health_check_interval=1,
|
|
36
|
+
)
|
|
28
37
|
|
|
29
38
|
return None
|
|
30
39
|
|
settings.py
CHANGED
|
@@ -14,6 +14,7 @@ ENV = os.getenv("ENV", "development")
|
|
|
14
14
|
IS_PRODUCTION = ENV == "production"
|
|
15
15
|
IS_PRODUCTION_CUSTOMER = env_to_bool("IS_PRODUCTION_CUSTOMER", IS_PRODUCTION)
|
|
16
16
|
IS_TESTING = env_to_bool("IS_TESTING", "pytest" in sys.argv[0] or sys.argv[0] == "-c")
|
|
17
|
+
IS_SCRIPT = env_to_bool("IS_SCRIPT", "plugin_runner.py" not in sys.argv[0])
|
|
17
18
|
CUSTOMER_IDENTIFIER = os.getenv("CUSTOMER_IDENTIFIER", "local")
|
|
18
19
|
APP_NAME = os.getenv("APP_NAME")
|
|
19
20
|
|
|
@@ -24,6 +25,9 @@ INTEGRATION_TEST_CLIENT_SECRET = os.getenv("INTEGRATION_TEST_CLIENT_SECRET")
|
|
|
24
25
|
GRAPHQL_ENDPOINT = os.getenv("GRAPHQL_ENDPOINT", "http://localhost:8000/plugins-graphql")
|
|
25
26
|
REDIS_ENDPOINT = os.getenv("REDIS_ENDPOINT", f"redis://{APP_NAME}-redis:6379")
|
|
26
27
|
|
|
28
|
+
|
|
29
|
+
METRICS_ENABLED = env_to_bool("PLUGINS_METRICS_ENABLED", not IS_SCRIPT)
|
|
30
|
+
|
|
27
31
|
INSTALLED_APPS = [
|
|
28
32
|
"canvas_sdk.v1",
|
|
29
33
|
]
|
|
@@ -42,12 +46,15 @@ CANVAS_SDK_DB_PASSWORD = os.getenv("CANVAS_SDK_DB_PASSWORD", "app")
|
|
|
42
46
|
CANVAS_SDK_DB_HOST = os.getenv("CANVAS_SDK_DB_HOST", "home-app-db")
|
|
43
47
|
CANVAS_SDK_DB_PORT = os.getenv("CANVAS_SDK_DB_PORT", "5432")
|
|
44
48
|
|
|
49
|
+
PLUGIN_RUNNER_MAX_WORKERS = int(os.getenv("PLUGIN_RUNNER_MAX_WORKERS", 5))
|
|
50
|
+
|
|
45
51
|
if os.getenv("DATABASE_URL"):
|
|
46
52
|
parsed_url = parse.urlparse(os.getenv("DATABASE_URL"))
|
|
47
53
|
|
|
48
54
|
DATABASES = {
|
|
49
55
|
"default": {
|
|
50
56
|
"ENGINE": "django.db.backends.postgresql",
|
|
57
|
+
"OPTIONS": {"pool": {"min_size": 2, "max_size": PLUGIN_RUNNER_MAX_WORKERS}},
|
|
51
58
|
"NAME": parsed_url.path[1:],
|
|
52
59
|
"USER": os.getenv("CANVAS_SDK_DATABASE_ROLE"),
|
|
53
60
|
"PASSWORD": os.getenv("CANVAS_SDK_DATABASE_ROLE_PASSWORD"),
|
|
@@ -59,6 +66,7 @@ else:
|
|
|
59
66
|
DATABASES = {
|
|
60
67
|
"default": {
|
|
61
68
|
"ENGINE": "django.db.backends.postgresql",
|
|
69
|
+
"OPTIONS": {"pool": {"min_size": 2, "max_size": PLUGIN_RUNNER_MAX_WORKERS}},
|
|
62
70
|
"NAME": CANVAS_SDK_DB_NAME,
|
|
63
71
|
"USER": CANVAS_SDK_DB_USERNAME,
|
|
64
72
|
"PASSWORD": CANVAS_SDK_DB_PASSWORD,
|