Patient Portal
The Canvas SDK allows you to configure and extend the patient portal. This page is the technical reference for the portal’s effects and events. For a decision-oriented overview of how much of the patient experience to build, see Choosing Your Patient Experience.
Configure Portal Menu Items #
Decide which items appear in the patient portal’s menu with the PatientPortalMenuConfiguration effect, returned from a handler on the PATIENT_PORTAL__MENU_CONFIGURATION event. Unlike the account-wide page toggles managed by Canvas Support, this lets you show or hide menu items with your own logic (per patient, program, and so on).
The list you return replaces the built-in menu — include every built-in item you want shown, in the order you want them to appear (the list order is the navigation order), and omit any you want hidden.
An item only appears if its page is also enabled in your portal settings (the PATIENT_APP_* settings managed by Canvas Support — see Managing the Patient Portal). This effect selects and orders among the enabled pages: it can hide or reorder them, but it can’t surface a page that’s turned off. Patient-portal applications aren’t part of this list — they’re appended to the navigation after the built-in items through their own registration.
Methods #
apply() → Effect #
Sets the portal menu for the patient.
itemsis required, with at least one item.
Attributes #
| Attribute | Type | Description | Required |
|---|---|---|---|
items | list[MenuItems] | The built-in menu items to show, in navigation order (at least one). Replaces the default set — omit an item to hide it. | Yes |
MenuItems #
| Member | Value | Menu item |
|---|---|---|
APPOINTMENTS | "appointments" | Appointments |
MESSAGING | "messaging" | Messaging |
MY_HEALTH | "my_health" | My Health |
PAYMENTS | "payments" | Payments |
LABS | "labs" | Lab results |
CONTACT | "contact" | Contact |
RECORDS | "records" | Health Records |
from canvas_sdk.effects import Effect
from canvas_sdk.effects.patient_portal_menu_configuration import PatientPortalMenuConfiguration
from canvas_sdk.events import EventType
from canvas_sdk.handlers import BaseHandler
class MyHandler(BaseHandler):
RESPONDS_TO = EventType.Name(EventType.PATIENT_PORTAL__MENU_CONFIGURATION)
def compute(self) -> list[Effect]:
return [
PatientPortalMenuConfiguration(
items=[
PatientPortalMenuConfiguration.MenuItems.APPOINTMENTS,
PatientPortalMenuConfiguration.MenuItems.MESSAGING,
]
).apply()
]
Portal Landing Page Widgets #
The PortalWidget class adds widgets to the patient portal landing page. Return PortalWidget effects from a handler on the PATIENT_PORTAL__WIDGET_CONFIGURATION event. You can fully customize a widget or use a ready-made one provided by Canvas (Appointments, Messaging).
Methods #
apply() → Effect #
Adds the widget to the landing page.
- Exactly one of
url,content, orcomponentis required; more than one, or none, raises an error. - A
componentwidget supports only theEXPANDEDsize.
Attributes #
| Attribute | Type | Description | Required |
|---|---|---|---|
url | str | A URL to load within the widget. | Exactly one of url, content, or component |
content | str | Content to display directly within the widget. | Exactly one of url, content, or component |
component | Component | A ready-made Canvas widget: APPOINTMENTS (upcoming appointments) or MESSAGING (quick messaging). | Exactly one of url, content, or component |
size | Size | Widget width on the grid: EXPANDED (12 columns), MEDIUM (8 columns), or COMPACT (4 columns). All are 300px tall. Defaults to EXPANDED. | No |
priority | int | Orders widgets within the portal; a lower number is higher priority. Defaults to 100. | No |
Example #
from canvas_sdk.effects import Effect
from canvas_sdk.effects.widgets import PortalWidget
from canvas_sdk.events import EventType
from canvas_sdk.handlers import BaseHandler
class MyHandler(BaseHandler):
RESPONDS_TO = EventType.Name(EventType.PATIENT_PORTAL__WIDGET_CONFIGURATION)
def compute(self) -> list[Effect]:
return [
PortalWidget(
url="https://example.com/info",
size=PortalWidget.Size.COMPACT,
priority=25,
).apply()
]
For a full walkthrough with examples, see the Custom Landing Page guide. Example plugins: patient_portal_plugin, and the MSF urgent-care-self-scheduler (a “Need to be seen today?” widget).
Customize Appointment Cards #
Customize your patient appointment cards so patients can easily join their telehealth appointments, cancel or reschedule appointments. Each appointment can be customized individually, allowing each one to have its own unique settings.
Each action is decided per appointment: Canvas fires a PATIENT_PORTAL__APPOINTMENT_CAN_* event for the appointment, and your handler returns the matching PATIENT_PORTAL__APPOINTMENT_IS_* (or PATIENT_PORTAL__APPOINTMENT_SHOW_MEETING_LINK) effect with a {"result": <bool>} payload — False hides the action; for the telehealth Join link, True shows it. Each action’s exact event, effect, and payload are shown below.
MSF example: portal_disable_cancel_appts hides the Cancel action.
Hide ‘Cancel’ button #
Respond to PATIENT_PORTAL__APPOINTMENT_CAN_BE_CANCELED and return a PATIENT_PORTAL__APPOINTMENT_IS_CANCELABLE effect with {"result": False} to hide the Cancel button.
import json
from canvas_sdk.effects import Effect, EffectType
from canvas_sdk.events import EventType
from canvas_sdk.handlers import BaseHandler
class MyHandler(BaseHandler):
RESPONDS_TO = EventType.Name(EventType.PATIENT_PORTAL__APPOINTMENT_CAN_BE_CANCELED)
def compute(self) -> list[Effect]:
return [
Effect(
type=EffectType.PATIENT_PORTAL__APPOINTMENT_IS_CANCELABLE,
payload=json.dumps({"result": False}))
]
Hide ‘Reschedule’ button #
Respond to PATIENT_PORTAL__APPOINTMENT_CAN_BE_RESCHEDULED and return a PATIENT_PORTAL__APPOINTMENT_IS_RESCHEDULABLE effect with {"result": False} to hide the Reschedule button.
import json
from canvas_sdk.effects import Effect, EffectType
from canvas_sdk.events import EventType
from canvas_sdk.handlers import BaseHandler
class MyHandler(BaseHandler):
RESPONDS_TO = EventType.Name(EventType.PATIENT_PORTAL__APPOINTMENT_CAN_BE_RESCHEDULED)
def compute(self) -> list[Effect]:
return [
Effect(
type=EffectType.PATIENT_PORTAL__APPOINTMENT_IS_RESCHEDULABLE,
payload=json.dumps({"result": False}))
]
Hide ‘Join’ button #
This button shows on telehealth appointments. Respond to PATIENT_PORTAL__APPOINTMENT_CAN_SHOW_MEETING_LINK and return a PATIENT_PORTAL__APPOINTMENT_SHOW_MEETING_LINK effect with {"result": True} to show the join link.
import json
from canvas_sdk.effects import Effect, EffectType
from canvas_sdk.events import EventType
from canvas_sdk.handlers import BaseHandler
class MyHandler(BaseHandler):
RESPONDS_TO = EventType.Name(EventType.PATIENT_PORTAL__APPOINTMENT_CAN_SHOW_MEETING_LINK)
def compute(self) -> list[Effect]:
return [
Effect(
type=EffectType.PATIENT_PORTAL__APPOINTMENT_SHOW_MEETING_LINK,
payload=json.dumps({"result": True}))
]
Configuring the Patient Portal #
Return a PatientPortalApplicationConfiguration effect from a handler on the PATIENT_PORTAL__GET_APPLICATION_CONFIGURATION event to set application-level portal options:
- Self-scheduling: show or hide the self-scheduling entry points. Pair this with Shape Self-Scheduling to control what patients can book.
- Default homepage: open one of your plugin’s portal applications, instead of the widgets landing page, when a patient lands on the portal home page.
Methods #
apply() → Effect #
Applies the portal options. Every attribute is optional; set only the options you want to change.
- If
default_homepage_application_identifieris set, it must match an installed application, or the effect raises a validation error.
Attributes #
| Attribute | Type | Description | Required |
|---|---|---|---|
can_schedule_appointments | bool | If the patient is allowed to book or reschedule appointments. When omitted, patients can self-schedule if at least one note type is schedulable through the patient portal. | No |
default_homepage_application_identifier | str | The application to open as the patient’s portal homepage. Use the application’s class string from CANVAS_MANIFEST.json, the <module path>:<ClassName> value. When omitted, patients see the widgets landing page. | No |
The portal opens the default homepage application only when that application uses the portal_menu_item scope and its plugin is enabled. Otherwise, patients see the widgets landing page.
While a default homepage application is set, every visit to the portal home page opens it, including the header logo link, so patients don’t see landing page widgets. Portal consents, forms, and modals from PATIENT_PORTAL__POST_LOGIN handlers still appear on top of the application. A sign-in link that names another portal page, such as messages, opens that page instead.
from canvas_sdk.effects import Effect
from canvas_sdk.effects.patient_portal.application_configuration import PatientPortalApplicationConfiguration
from canvas_sdk.events import EventType
from canvas_sdk.handlers import BaseHandler
class MyHandler(BaseHandler):
RESPONDS_TO = EventType.Name(EventType.PATIENT_PORTAL__GET_APPLICATION_CONFIGURATION)
def compute(self) -> list[Effect]:
return [
PatientPortalApplicationConfiguration(
can_schedule_appointments=True,
default_homepage_application_identifier="my_plugin.apps.care_dashboard:CareDashboardApp",
).apply()
]
Shape Self-Scheduling #
When patients self-schedule, you can filter or reorder what they’re offered before they see it — the open time slots, and the appointment-type, location, and provider options on the scheduling form. Each search step fires a …PRE_SEARCH event (before Canvas runs the search) and a …POST_SEARCH event (with the results). Respond to the POST_SEARCH event and return an effect of the same name with a _RESULTS suffix, carrying the results you want the patient to see. (A …PRE_SEARCH event is available for each step too, if you need to act before the search runs.)
Two example plugins put these together: patient_portal_search_appointments_slots_plugin (slots) and patient_app_schedule (appointment types, locations, and providers).
Filter available slots #
Respond to PATIENT_PORTAL__APPOINTMENTS__SLOTS__POST_SEARCH and return PATIENT_PORTAL__APPOINTMENTS__SLOTS__POST_SEARCH_RESULTS.
Target: self.target is the Patient id.
Context: self.context["slots_by_provider"] — a JSON string of {provider: {date: [{"start", "end"}, ...]}}, keyed by Staff id, where each start/end is an ISO-8601 datetime.
Example — enforce a 48-hour minimum lead time so patients can’t self-book last minute:
import json
import arrow
from canvas_sdk.effects import Effect, EffectType
from canvas_sdk.events import EventType
from canvas_sdk.handlers import BaseHandler
# Patients must book at least this far ahead — no last-minute self-scheduling
MIN_LEAD_TIME_HOURS = 48
class SlotFilter(BaseHandler):
RESPONDS_TO = EventType.Name(EventType.PATIENT_PORTAL__APPOINTMENTS__SLOTS__POST_SEARCH)
def compute(self) -> list[Effect]:
# slots_by_provider maps each provider to {date: [{"start": ..., "end": ...}, ...]}
slots_by_provider = json.loads(self.context.get("slots_by_provider") or "{}")
cutoff = arrow.now().shift(hours=MIN_LEAD_TIME_HOURS)
filtered = {}
for provider, dates in slots_by_provider.items():
# drop any slot starting before the cutoff
kept = {
date: [slot for slot in slots if arrow.get(slot["start"]) >= cutoff]
for date, slots in dates.items()
}
# then drop empty dates, and providers left with no slots
kept = {date: slots for date, slots in kept.items() if slots}
if kept:
filtered[provider] = kept
return [
Effect(
type=EffectType.PATIENT_PORTAL__APPOINTMENTS__SLOTS__POST_SEARCH_RESULTS,
payload=json.dumps({"slots_by_provider": filtered}),
)
]
Filter appointment types #
Respond to PATIENT_PORTAL__APPOINTMENTS__FORM_APPOINTMENT_TYPES__POST_SEARCH and return PATIENT_PORTAL__APPOINTMENTS__FORM_APPOINTMENT_TYPES__POST_SEARCH_RESULTS.
Target: self.target is the Patient id.
Context: self.context["appointment_types"] — a list of {"id", "title"} entries, where id is a NoteType id (its unique_identifier) and title is the note type’s name.
Example — only let patients self-book a specific set of visit types (e.g. keep follow-ups and telehealth, hide new-patient intakes):
import json
from canvas_sdk.effects import Effect, EffectType
from canvas_sdk.events import EventType
from canvas_sdk.handlers import BaseHandler
# Visit types patients are allowed to self-book
PATIENT_BOOKABLE_TYPES = {"Follow-up", "Telehealth visit"}
class AppointmentTypeFilter(BaseHandler):
RESPONDS_TO = EventType.Name(
EventType.PATIENT_PORTAL__APPOINTMENTS__FORM_APPOINTMENT_TYPES__POST_SEARCH
)
def compute(self) -> list[Effect]:
# each entry is {"id": ..., "title": ...}
appointment_types = self.context.get("appointment_types", [])
filtered = [t for t in appointment_types if t["title"] in PATIENT_BOOKABLE_TYPES]
return [
Effect(
type=EffectType.PATIENT_PORTAL__APPOINTMENTS__FORM_APPOINTMENT_TYPES__POST_SEARCH_RESULTS,
payload=json.dumps({"appointment_types": filtered}),
)
]
Filter locations #
Respond to PATIENT_PORTAL__APPOINTMENTS__FORM_LOCATIONS__POST_SEARCH and return PATIENT_PORTAL__APPOINTMENTS__FORM_LOCATIONS__POST_SEARCH_RESULTS.
Target: self.target is the Patient id.
Context: self.context["locations"] — a list of {"id", "title"} entries, where id is a PracticeLocation id and title is its full name.
Example — only show locations where the patient has been seen before:
import json
from canvas_sdk.effects import Effect, EffectType
from canvas_sdk.events import EventType
from canvas_sdk.handlers import BaseHandler
from canvas_sdk.v1.data import Appointment
class LocationFilter(BaseHandler):
RESPONDS_TO = EventType.Name(
EventType.PATIENT_PORTAL__APPOINTMENTS__FORM_LOCATIONS__POST_SEARCH
)
def compute(self) -> list[Effect]:
locations = self.context.get("locations", [])
seen_location_ids = {
str(loc_id)
for loc_id in Appointment.objects.filter(patient__id=self.target)
.exclude(location__isnull=True)
.values_list("location__id", flat=True)
}
filtered = [loc for loc in locations if loc["id"] in seen_location_ids]
return [
Effect(
type=EffectType.PATIENT_PORTAL__APPOINTMENTS__FORM_LOCATIONS__POST_SEARCH_RESULTS,
payload=json.dumps({"locations": filtered}),
)
]
Filter providers #
Respond to PATIENT_PORTAL__APPOINTMENTS__FORM_PROVIDERS__POST_SEARCH and return PATIENT_PORTAL__APPOINTMENTS__FORM_PROVIDERS__POST_SEARCH_RESULTS.
Target: self.target is the Patient id.
Context: self.context["providers"] — a list of {"id", "title"} entries, where id is a Staff id and title is the provider’s name and roles.
Example — only offer the patient’s own care-team providers:
import json
from canvas_sdk.effects import Effect, EffectType
from canvas_sdk.events import EventType
from canvas_sdk.handlers import BaseHandler
from canvas_sdk.v1.data import CareTeamMembership
from canvas_sdk.v1.data.care_team import CareTeamMembershipStatus
class ProviderFilter(BaseHandler):
RESPONDS_TO = EventType.Name(
EventType.PATIENT_PORTAL__APPOINTMENTS__FORM_PROVIDERS__POST_SEARCH
)
def compute(self) -> list[Effect]:
providers = self.context.get("providers", [])
care_team_ids = {
str(staff_id)
for staff_id in CareTeamMembership.objects.filter(
patient__id=self.target, status=CareTeamMembershipStatus.ACTIVE
).values_list("staff__id", flat=True)
}
filtered = [p for p in providers if p["id"] in care_team_ids]
return [
Effect(
type=EffectType.PATIENT_PORTAL__APPOINTMENTS__FORM_PROVIDERS__POST_SEARCH_RESULTS,
payload=json.dumps({"providers": filtered}),
)
]
Show a Modal After Login #
Show a patient a consent form, a tour, or an alert as soon as they log in to the portal. Respond to the PATIENT_PORTAL__POST_LOGIN event and return a LaunchModalEffect with the DEFAULT_MODAL target. The portal opens the modal over the page the patient lands on.
The event targets the patient. Its context carries login_method, which tells you how the patient logged in:
login_method | How the patient logged in |
|---|---|
"credentials" | Entered their username and password on the login page, including after setting a password from a portal invite or password reset link |
"access_token" | Followed the sign-in link in a message, lab result, or statement notification |
"registration" | Completed self-registration |
The event fires once per login, in the browser tab where the patient logged in. Refreshing the page or returning to the portal with a remembered session doesn’t fire it. When no handler responds, or the plugin runner fails, the patient lands in the portal with no modal.
from canvas_sdk.effects import Effect
from canvas_sdk.effects.launch_modal import LaunchModalEffect
from canvas_sdk.events import EventType
from canvas_sdk.handlers import BaseHandler
class WelcomeModal(BaseHandler):
RESPONDS_TO = EventType.Name(EventType.PATIENT_PORTAL__POST_LOGIN)
def compute(self) -> list[Effect]:
# Only greet patients who just registered
if self.context.get("login_method") != "registration":
return []
return [
LaunchModalEffect(
content="<h2>Welcome to the portal</h2><p>Here's how to get started.</p>",
target=LaunchModalEffect.TargetType.DEFAULT_MODAL,
title="Welcome",
).apply()
]
Link to After-Visit Summaries #
A patient can download the after-visit summary of their own locked notes from the portal at /app/note/<note id>/aftervisitsummary, where <note id> is the note’s id. The portal has no built-in link to it, so share the link from a plugin, such as a widget or a portal page, or in a message.
- The PDF is the same after-visit summary a clinician prints from the note.
- Only the patient the note belongs to can open it, and only once the note is locked. Any other note id returns a 404 error.
- Each download is recorded in the audit log against the patient.
Each download also fires the PATIENT_PORTAL__DOCUMENT_DOWNLOADED event, so your plugin can keep its own record of which summaries a patient has received. The event targets the patient, and its context carries the document ("after_visit_summary") and the note_id. It is sent after the PDF is served, so a handler can’t delay or block the download.
from canvas_sdk.effects import Effect
from canvas_sdk.events import EventType
from canvas_sdk.handlers import BaseHandler
from logger import log
class TrackSummaryDownloads(BaseHandler):
RESPONDS_TO = EventType.Name(EventType.PATIENT_PORTAL__DOCUMENT_DOWNLOADED)
def compute(self) -> list[Effect]:
if self.context.get("document") != "after_visit_summary":
return []
log.info(f"After-visit summary downloaded for note {self.context['note_id']}")
return []
Forms #
Forms let you dynamically display questionnaires to patients in the portal based on your own criteria, and commit each response to the patient’s chart as a Questionnaire Command when they submit. Because your handler runs on every portal page load, return only the forms that should currently appear.
Respond to the PATIENT_PORTAL__GET_FORMS event and return a FormResult effect for each questionnaire to show.
Methods #
apply() → Effect #
Shows the questionnaire to the patient.
questionnaire_idis required.
Attributes #
| Attribute | Type | Description | Required |
|---|---|---|---|
questionnaire_id | str | UUID | The unique ID of the Questionnaire to show. | Yes |
create_command | bool | Strongly recommended True. Commits the response as a Questionnaire Command in a note so it appears in the chart. When False (the default), the response is saved only as an Interview and never appears in a note. | No |
note_id | str | UUID | None | The Note to place the command on. Only used when create_command is True; if omitted, Canvas creates and locks a new note for it. Has no effect on its own. | No |
Example #
Assign intake questionnaires when a patient has a confirmed upcoming visit, skipping any they’ve already completed:
import arrow
from canvas_sdk.effects.patient_portal.form_result import FormResult
from canvas_sdk.events import EventType
from canvas_sdk.handlers import BaseHandler
from canvas_sdk.v1.data import Interview, Patient, Questionnaire
from canvas_sdk.v1.data.appointment import AppointmentProgressStatus
INTAKE_QUESTIONNAIRES = ["Insurance Details", "Preferred Pharmacy Details", "Social History"]
APPOINTMENT_NOTE_TYPES = ["Telehealth", "Office visit"]
class Handler(BaseHandler):
RESPONDS_TO = EventType.Name(EventType.PATIENT_PORTAL__GET_FORMS)
def _upcoming_appointment_note_id(self, appointments):
return appointments.filter(
status=AppointmentProgressStatus.CONFIRMED,
start_time__gt=arrow.now().date(),
note_type__name__in=APPOINTMENT_NOTE_TYPES,
).values_list("note__id", flat=True).first()
def compute(self):
patient = Patient.objects.get(id=self.target)
# Only assign intake forms when there's a confirmed upcoming visit
note_id = self._upcoming_appointment_note_id(patient.appointments)
if not note_id:
return []
# Which intake questionnaires are already completed *on this visit's note*.
# committed() automatically drops entered-in-error and uncommitted interviews.
# The same intake form is expected each visit, so scope the check to this note
# rather than the whole patient.
completed = set(
Interview.objects.for_patient(patient.id)
.committed()
.filter(note_id=note_id, questionnaires__name__in=INTAKE_QUESTIONNAIRES)
.values_list("questionnaires__name", flat=True)
)
missing = [name for name in INTAKE_QUESTIONNAIRES if name not in completed]
missing_ids = Questionnaire.objects.filter(name__in=missing).values_list("id", flat=True)
return [
FormResult(questionnaire_id=qid, create_command=True, note_id=note_id).apply()
for qid in missing_ids
]
Best practices:
- Match questionnaires by name, not ID — it always resolves to the latest version. When a questionnaire is updated, the previous version is archived with a
(v#)suffix on its name (e.g.Social History (v1)) while the current version keeps the clean name. Filtering by the exact name therefore returns only the latest version, and (as a bonus) stays portable across instances, where IDs differ. - Prevent duplicates — but scope the check. This logic runs on every page load, so check existing committed
Interviewresponses before assigning a form again. Because the same intake form is typically expected at every visit, scope that check to the current appointment’snote_idrather than the whole patient (use.committed()so entered-in-error responses are ignored automatically). - Don’t let forms linger. Return a questionnaire only while it should be shown; once it’s completed or no longer relevant, stop returning it.
- Always land responses in the chart. Set
create_command=Trueso each submission is committed as a Questionnaire Command; pass anote_idto target a specific note, or omit it to let Canvas create one. Withoutcreate_command, the response is only an Interview and the care team won’t see it.
Real-world example: the MSF patient-portal-forms extension assigns questionnaires (PHQ-9, GAD-7, ROS…) that patients complete in the portal, posting responses back to the chart.
Writing answers back to the chart #
A submitted form is stored as an Interview, which links the patient to the questionnaire. To use the answers elsewhere in the chart, such as updating the patient’s demographics, respond to the INTERVIEW_CREATED event, and to INTERVIEW_UPDATED as well, since answers can arrive after the Interview is created. The event targets the Interview and carries the patient’s id. Read the answers from the Interview, then:
- Update demographics, contact details, and other patient fields with the patient effects.
- Record insurance coverage through the FHIR Coverage API, using the Canvas FHIR client, since no SDK effect writes coverage.
User Login #
These effects manage a patient’s portal user account — the login they use to access the portal. Invites and password resets are sent to the email or phone linked on the user, so make sure those contact points are actually verified before you invite a patient or rely on an updated value. Trigger a verification with the Send Contact Verification effect.
Update User #
The UpdateUserEffect updates a portal user’s phone number or email. In the future this may support additional attributes, but for now only these two are supported. It isn’t tied to a specific portal event — emit it from whatever handler fits your workflow (a common one is keeping the portal user in sync in response to PATIENT_CONTACT_POINT_UPDATED, as below).
Methods #
apply() → Effect #
Updates the user’s phone number or email.
user_dbidis required, and must be thedbidof an existing user; otherwise the effect returns aUser does not existerror.- Only the fields you set are changed, and setting one to
Noneclears it. With neither set, nothing changes.
Attributes #
| Attribute | Type | Description | Required |
|---|---|---|---|
user_dbid | int | The dbid of the CanvasUser to update. | Yes |
phone_number | str | The phone number to store. | No |
email | str | The email to store. | No |
from canvas_sdk.effects import Effect
from canvas_sdk.effects.update_user import UpdateUserEffect
from canvas_sdk.events import EventType
from canvas_sdk.handlers import BaseHandler
from canvas_sdk.v1.data import PatientContactPoint
from canvas_sdk.v1.data.common import ContactPointSystem
class SyncPortalUser(BaseHandler):
RESPONDS_TO = EventType.Name(EventType.PATIENT_CONTACT_POINT_UPDATED)
def compute(self) -> list[Effect]:
contact_point = PatientContactPoint.objects.get(id=self.target)
# Only sync verified contact points. last_verified is set once the patient
# completes the verification challenge (see Send Contact Verification); until
# then the value shouldn't be trusted as the account's login channel.
if not contact_point.last_verified:
return []
user = contact_point.patient.user
# Only update when the value actually changed. Emitting an effect that sets the
# user to what it already is is a no-op — and can retrigger
# PATIENT_CONTACT_POINT_UPDATED, causing an update loop.
if contact_point.system == ContactPointSystem.EMAIL and user.email != contact_point.value:
return [UpdateUserEffect(user_dbid=user.dbid, email=contact_point.value).apply()]
if contact_point.system == ContactPointSystem.PHONE and user.phone_number != contact_point.value:
return [UpdateUserEffect(user_dbid=user.dbid, phone_number=contact_point.value).apply()]
return []
Send Invite #
The SendInviteEffect triggers a portal invitation that lets the patient register or activate their portal account. Because the invite is delivered to the patient’s email or phone, only send it once that contact point is verified (PatientContactPoint.last_verified is set — see Send Contact Verification) and the patient hasn’t already registered (CanvasUser.is_portal_registered). Verification completes with a save that fires PATIENT_CONTACT_POINT_UPDATED, which makes it a natural trigger for the invite. Re-inviting someone who has already activated their account just sends a redundant email/SMS.
Methods #
apply() → Effect #
Sends the portal invitation.
user_dbidis required, and must be thedbidof an existing user; otherwise the effect returns aUser does not existerror.
Attributes #
| Attribute | Type | Description | Required |
|---|---|---|---|
user_dbid | int | The dbid of the CanvasUser to invite. | Yes |
from canvas_sdk.effects import Effect
from canvas_sdk.effects.send_invite import SendInviteEffect
from canvas_sdk.events import EventType
from canvas_sdk.handlers import BaseHandler
from canvas_sdk.v1.data import PatientContactPoint
class InviteVerifiedPatient(BaseHandler):
# Verification completes with a save that fires this event
RESPONDS_TO = EventType.Name(EventType.PATIENT_CONTACT_POINT_UPDATED)
def compute(self) -> list[Effect]:
contact_point = PatientContactPoint.objects.get(id=self.target)
# Only invite once the contact point is actually verified — that's the
# channel the invitation will be delivered to.
if not contact_point.last_verified:
return []
user = contact_point.patient.user
# Only invite once: skip if there's no portal user yet, or they've already registered
if user is None or user.is_portal_registered:
return []
return [SendInviteEffect(user_dbid=user.dbid).apply()]
Sending versus minting a link. SendInviteEffect lets Canvas send the invite over the patient’s verified contact point, and it records the send on CanvasUser.last_invite_date_time. To deliver the invite or password reset over your own transport instead, mint a link with patient_portal_http.get_login_url() and send it yourself. Minting a link does not update last_invite_date_time; only SendInviteEffect does. Canvas has no record that you delivered the link, so the patient’s Portal section is unchanged and staff may send a second invite to a patient who already holds a live link.
What the patient does with the link is still visible in that section. Setting up the account sets is_portal_registered on the CanvasUser data module, which replaces the pending username with theirs and changes the button to Reset password. Signing in is a separate step that sets last_login, which is what replaces “The patient has never logged in to the patient portal.” with a last login time. Opening the link and setting a password does not sign the patient in, so a registered patient who has not yet signed in shows both their username and the never-logged-in line.