Skip to content

Commit

Permalink
feat: allow html to be used in LaunchModalEffect (#306)
Browse files Browse the repository at this point in the history
  • Loading branch information
jamagalhaes authored Jan 28, 2025
1 parent 90613dc commit 5976b59
Show file tree
Hide file tree
Showing 22 changed files with 414 additions and 74 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@
"plugin_version": "0.0.1",
"name": "{{ cookiecutter.__project_slug }}",
"description": "Edit the description in CANVAS_MANIFEST.json",
"origins": {
"urls": ["https://www.canvasmedical.com/extensions"],
"scripts": []
},
"components": {
"applications": [
{
"class": "{{ cookiecutter.__project_slug }}.applications.my_application:MyApplication",
"name": "My Application",
"name": "{{ cookiecutter.project_name }}",
"description": "An Application that does xyz...",
"scope": "global",
"icon": "assets/python-logo.png",
"origins": []
"icon": "assets/python-logo.png"
}
],
"commands": [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,7 @@ class MyApplication(Application):
def on_open(self) -> Effect:
"""Handle the on_open event."""
# Implement this method to handle the application on_open event.
return LaunchModalEffect(url="", target=LaunchModalEffect.TargetType.DEFAULT_MODAL).apply()
return LaunchModalEffect(
url="https://www.canvasmedical.com/extensions",
target=LaunchModalEffect.TargetType.DEFAULT_MODAL,
).apply()
11 changes: 9 additions & 2 deletions canvas_cli/utils/validators/manifest_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"name": {"type": "string"},
"description": {"type": "string"},
"secrets": {"type": "array", "items": {"type": "string"}},
"origins": {"$ref": "#/$defs/origins"},
"components": {
"type": "object",
"properties": {
Expand Down Expand Up @@ -55,6 +56,13 @@
],
"additionalProperties": False,
"$defs": {
"origins": {
"type": "object",
"properties": {
"urls": {"type": "array", "items": {"type": "string"}},
"scripts": {"type": "array", "items": {"type": "string"}},
},
},
"component": {
"type": "array",
"items": {
Expand Down Expand Up @@ -88,9 +96,8 @@
"description": {"type": "string", "maxLength": 256},
"icon": {"type": "string"},
"scope": {"type": "string", "enum": ["patient_specific", "global"]},
"origins": {"type": "array", "items": {"type": "string"}},
},
"required": ["class", "icon", "scope", "name", "description", "origins"],
"required": ["class", "icon", "scope", "name", "description"],
"additionalProperties": False,
},
},
Expand Down
4 changes: 2 additions & 2 deletions canvas_generated/messages/events_pb2.py

Large diffs are not rendered by default.

22 changes: 22 additions & 0 deletions canvas_generated/messages/events_pb2.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,17 @@ class EventType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
PATIENT_PORTAL__APPOINTMENT_RESCHEDULED: _ClassVar[EventType]
PATIENT_PORTAL__APPOINTMENT_CAN_BE_CANCELED: _ClassVar[EventType]
PATIENT_PORTAL__APPOINTMENT_CAN_BE_RESCHEDULED: _ClassVar[EventType]
SHOW_CHART_SUMMARY_SOCIAL_DETERMINANTS_SECTION_BUTTON: _ClassVar[EventType]
SHOW_CHART_SUMMARY_GOALS_SECTION_BUTTON: _ClassVar[EventType]
SHOW_CHART_SUMMARY_CONDITIONS_SECTION_BUTTON: _ClassVar[EventType]
SHOW_CHART_SUMMARY_MEDICATIONS_SECTION_BUTTON: _ClassVar[EventType]
SHOW_CHART_SUMMARY_ALLERGIES_SECTION_BUTTON: _ClassVar[EventType]
SHOW_CHART_SUMMARY_CARE_TEAMS_SECTION_BUTTON: _ClassVar[EventType]
SHOW_CHART_SUMMARY_VITALS_SECTION_BUTTON: _ClassVar[EventType]
SHOW_CHART_SUMMARY_IMMUNIZATIONS_SECTION_BUTTON: _ClassVar[EventType]
SHOW_CHART_SUMMARY_SURGICAL_HISTORY_SECTION_BUTTON: _ClassVar[EventType]
SHOW_CHART_SUMMARY_FAMILY_HISTORY_SECTION_BUTTON: _ClassVar[EventType]
SHOW_CHART_SUMMARY_CODING_GAPS_SECTION_BUTTON: _ClassVar[EventType]
UNKNOWN: EventType
ALLERGY_INTOLERANCE_CREATED: EventType
ALLERGY_INTOLERANCE_UPDATED: EventType
Expand Down Expand Up @@ -1317,6 +1328,17 @@ PATIENT_PORTAL__APPOINTMENT_CANCELED: EventType
PATIENT_PORTAL__APPOINTMENT_RESCHEDULED: EventType
PATIENT_PORTAL__APPOINTMENT_CAN_BE_CANCELED: EventType
PATIENT_PORTAL__APPOINTMENT_CAN_BE_RESCHEDULED: EventType
SHOW_CHART_SUMMARY_SOCIAL_DETERMINANTS_SECTION_BUTTON: EventType
SHOW_CHART_SUMMARY_GOALS_SECTION_BUTTON: EventType
SHOW_CHART_SUMMARY_CONDITIONS_SECTION_BUTTON: EventType
SHOW_CHART_SUMMARY_MEDICATIONS_SECTION_BUTTON: EventType
SHOW_CHART_SUMMARY_ALLERGIES_SECTION_BUTTON: EventType
SHOW_CHART_SUMMARY_CARE_TEAMS_SECTION_BUTTON: EventType
SHOW_CHART_SUMMARY_VITALS_SECTION_BUTTON: EventType
SHOW_CHART_SUMMARY_IMMUNIZATIONS_SECTION_BUTTON: EventType
SHOW_CHART_SUMMARY_SURGICAL_HISTORY_SECTION_BUTTON: EventType
SHOW_CHART_SUMMARY_FAMILY_HISTORY_SECTION_BUTTON: EventType
SHOW_CHART_SUMMARY_CODING_GAPS_SECTION_BUTTON: EventType

class Event(_message.Message):
__slots__ = ("type", "target", "context", "target_type")
Expand Down
17 changes: 14 additions & 3 deletions canvas_sdk/effects/launch_modal.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from enum import StrEnum
from typing import Any
from typing import Any, Self

from pydantic import model_validator

from canvas_sdk.effects import EffectType, _BaseEffect

Expand All @@ -15,10 +17,19 @@ class TargetType(StrEnum):
NEW_WINDOW = "new_window"
RIGHT_CHART_PANE = "right_chart_pane"

url: str
url: str | None = None
content: str | None = None
target: TargetType = TargetType.DEFAULT_MODAL

@property
def values(self) -> dict[str, Any]:
"""The LaunchModalEffect values."""
return {"url": self.url, "target": self.target.value}
return {"url": self.url, "content": self.content, "target": self.target.value}

@model_validator(mode="after")
def check_mutually_exclusive_fields(self) -> Self:
"""Check that url and content are mutually exclusive."""
if self.url is not None and self.content is not None:
raise ValueError("'url' and 'content' are mutually exclusive")

return self
49 changes: 33 additions & 16 deletions canvas_sdk/handlers/action_button.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import re
from abc import abstractmethod
from enum import StrEnum

Expand All @@ -6,23 +7,47 @@
from canvas_sdk.events import EventType
from canvas_sdk.handlers.base import BaseHandler

SHOW_BUTTON_REGEX = re.compile(r"^SHOW_(.+?)_BUTTON$")


class ActionButton(BaseHandler):
"""Base class for action buttons."""

RESPONDS_TO = [
EventType.Name(EventType.SHOW_NOTE_HEADER_BUTTON),
EventType.Name(EventType.SHOW_NOTE_FOOTER_BUTTON),
EventType.Name(EventType.SHOW_CHART_SUMMARY_SOCIAL_DETERMINANTS_SECTION_BUTTON),
EventType.Name(EventType.SHOW_CHART_SUMMARY_GOALS_SECTION_BUTTON),
EventType.Name(EventType.SHOW_CHART_SUMMARY_CONDITIONS_SECTION_BUTTON),
EventType.Name(EventType.SHOW_CHART_SUMMARY_MEDICATIONS_SECTION_BUTTON),
EventType.Name(EventType.SHOW_CHART_SUMMARY_ALLERGIES_SECTION_BUTTON),
EventType.Name(EventType.SHOW_CHART_SUMMARY_CARE_TEAMS_SECTION_BUTTON),
EventType.Name(EventType.SHOW_CHART_SUMMARY_VITALS_SECTION_BUTTON),
EventType.Name(EventType.SHOW_CHART_SUMMARY_IMMUNIZATIONS_SECTION_BUTTON),
EventType.Name(EventType.SHOW_CHART_SUMMARY_SURGICAL_HISTORY_SECTION_BUTTON),
EventType.Name(EventType.SHOW_CHART_SUMMARY_FAMILY_HISTORY_SECTION_BUTTON),
EventType.Name(EventType.SHOW_CHART_SUMMARY_CODING_GAPS_SECTION_BUTTON),
EventType.Name(EventType.ACTION_BUTTON_CLICKED),
]

class ButtonLocation(StrEnum):
NOTE_HEADER = "note_header"
NOTE_FOOTER = "note_footer"
CHART_SUMMARY_SOCIAL_DETERMINANTS_SECTION = "chart_summary_social_determinants_section"
CHART_SUMMARY_GOALS_SECTION = "chart_summary_goals_section"
CHART_SUMMARY_CONDITIONS_SECTION = "chart_summary_conditions_section"
CHART_SUMMARY_MEDICATIONS_SECTION = "chart_summary_medications_section"
CHART_SUMMARY_ALLERGIES_SECTION = "chart_summary_allergies_section"
CHART_SUMMARY_CARE_TEAMS_SECTION = "chart_summary_care_teams_section"
CHART_SUMMARY_VITALS_SECTION = "chart_summary_vitals_section"
CHART_SUMMARY_IMMUNIZATIONS_SECTION = "chart_summary_immunizations_section"
CHART_SUMMARY_SURGICAL_HISTORY_SECTION = "chart_summary_surgical_history_section"
CHART_SUMMARY_FAMILY_HISTORY_SECTION = "chart_summary_family_history_section"
CHART_SUMMARY_CODING_GAPS_SECTION = "chart_summary_coding_gaps_section"

BUTTON_TITLE: str = ""
BUTTON_KEY: str = ""
BUTTON_LOCATION: ButtonLocation | None = None
BUTTON_LOCATION: ButtonLocation

@abstractmethod
def handle(self) -> list[Effect]:
Expand All @@ -35,24 +60,16 @@ def visible(self) -> bool:

def compute(self) -> list[Effect]:
"""Method to compute the effects."""
if self.BUTTON_LOCATION is None:
if not self.BUTTON_LOCATION:
return []

if self.event.type in (
EventType.SHOW_NOTE_HEADER_BUTTON,
EventType.SHOW_NOTE_FOOTER_BUTTON,
):
if (
self.event.context["location"].lower() == self.BUTTON_LOCATION.value
and self.visible()
):
show_button_event_match = SHOW_BUTTON_REGEX.fullmatch(self.event.name)

if show_button_event_match:
location = show_button_event_match.group(1)
if self.ButtonLocation[location] == self.BUTTON_LOCATION and self.visible():
return [ShowButtonEffect(key=self.BUTTON_KEY, title=self.BUTTON_TITLE).apply()]
else:
return []
elif (
self.event.type == EventType.ACTION_BUTTON_CLICKED
and self.event.context["key"] == self.BUTTON_KEY
):
elif self.context["key"] == self.BUTTON_KEY:
return self.handle()

return []
3 changes: 3 additions & 0 deletions canvas_sdk/templates/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .utils import render_to_string

__all__ = ("render_to_string",)
Empty file.
43 changes: 43 additions & 0 deletions canvas_sdk/templates/tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from pathlib import Path

import pytest

from canvas_sdk.effects import Effect
from canvas_sdk.events import Event, EventRequest, EventType
from plugin_runner.plugin_runner import LOADED_PLUGINS


@pytest.mark.parametrize("install_test_plugin", ["test_render_template"], indirect=True)
def test_render_to_string_valid_template(
install_test_plugin: Path, load_test_plugins: None
) -> None:
"""Test that the render_to_string function loads and renders a valid template."""
plugin = LOADED_PLUGINS[
"test_render_template:test_render_template.protocols.my_protocol:ValidTemplate"
]
result: list[Effect] = plugin["class"](Event(EventRequest(type=EventType.UNKNOWN))).compute()
assert "html" in result[0].payload


@pytest.mark.parametrize("install_test_plugin", ["test_render_template"], indirect=True)
def test_render_to_string_invalid_template(
install_test_plugin: Path, load_test_plugins: None
) -> None:
"""Test that the render_to_string function raises an error for invalid templates."""
plugin = LOADED_PLUGINS[
"test_render_template:test_render_template.protocols.my_protocol:InvalidTemplate"
]
with pytest.raises(FileNotFoundError):
plugin["class"](Event(EventRequest(type=EventType.UNKNOWN))).compute()


@pytest.mark.parametrize("install_test_plugin", ["test_render_template"], indirect=True)
def test_render_to_string_forbidden_template(
install_test_plugin: Path, load_test_plugins: None
) -> None:
"""Test that the render_to_string function raises an error for a template outside plugin package."""
plugin = LOADED_PLUGINS[
"test_render_template:test_render_template.protocols.my_protocol:ForbiddenTemplate"
]
with pytest.raises(PermissionError):
plugin["class"](Event(EventRequest(type=EventType.UNKNOWN))).compute()
44 changes: 44 additions & 0 deletions canvas_sdk/templates/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import inspect
from pathlib import Path
from typing import Any

from django.template import Context, Template

from settings import PLUGIN_DIRECTORY


def render_to_string(template_name: str, context: dict[str, Any] | None = None) -> str | None:
"""Load a template and render it with the given context.
Args:
template_name (str): The path to the template file, relative to the plugin package.
If the path starts with a forward slash ("/"), it will be stripped during resolution.
context (dict[str, Any] | None): A dictionary of variables to pass to the template
for rendering. Defaults to None, which uses an empty context.
Returns:
str: The rendered template as a string.
Raises:
FileNotFoundError: If the template file does not exist within the plugin's directory
or if the resolved path is invalid.
"""
plugins_dir = Path(PLUGIN_DIRECTORY).resolve()
current_frame = inspect.currentframe()
caller = current_frame.f_back if current_frame else None

if not caller or "__is_plugin__" not in caller.f_globals:
return None

plugin_name = caller.f_globals["__name__"].split(".")[0]
plugin_dir = plugins_dir / plugin_name
template_path = Path(plugin_dir / template_name.lstrip("/")).resolve()

if not template_path.is_relative_to(plugin_dir):
raise PermissionError(f"Invalid template '{template_name}'")
elif not template_path.exists():
raise FileNotFoundError(f"Template {template_name} not found.")

template = Template(template_path.read_text())

return template.render(Context(context))
52 changes: 52 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import shutil
from collections.abc import Generator
from pathlib import Path

import pytest

from plugin_runner.plugin_runner import EVENT_HANDLER_MAP, LOADED_PLUGINS, load_plugins


@pytest.fixture
def install_test_plugin(request: pytest.FixtureRequest) -> Generator[Path, None, None]:
"""Copies a specified plugin from the fixtures directory to the data directory
and removes it after the test.
Parameters:
- request.param: The name of the plugin package to copy.
Yields:
- Path to the copied plugin directory.
"""
# Define base directories
base_dir = Path("./plugin_runner/tests")
fixture_plugin_dir = base_dir / "fixtures" / "plugins"
data_plugin_dir = base_dir / "data" / "plugins"

# The plugin name should be passed as a parameter to the fixture
plugin_name = request.param # Expected to be a str
src_plugin_path = fixture_plugin_dir / plugin_name
dest_plugin_path = data_plugin_dir / plugin_name

# Ensure the data plugin directory exists
data_plugin_dir.mkdir(parents=True, exist_ok=True)

# Copy the specific plugin from fixtures to data
try:
shutil.copytree(src_plugin_path, dest_plugin_path)
yield dest_plugin_path # Provide the path to the test
finally:
# Cleanup: remove data/plugins directory after the test
if dest_plugin_path.exists():
shutil.rmtree(dest_plugin_path)


@pytest.fixture
def load_test_plugins() -> Generator[None, None, None]:
"""Manages the lifecycle of test plugins by loading and unloading them."""
try:
load_plugins()
yield
finally:
LOADED_PLUGINS.clear()
EVENT_HANDLER_MAP.clear()
Loading

0 comments on commit 5976b59

Please sign in to comment.