Scheduling with Rooms | Canvas Medical

kristenoneill/scheduling-with-rooms

Description

Custom scheduling modal that coordinates provider and resource (room) availability. Patients are booked against a provider's calendar, and when the visit type requires a room, a corresponding ScheduleEvent is created on the room's calendar in lockstep. Cancelling the patient appointment cascades to the room event, so a cancelled visit releases its room.

The Scheduling Admin app exposes a visit-type configuration matrix (allowed durations, eligible rooms, room-event note types, per-staff concurrent-slot capacity) persisted under the scheduling_with_rooms Custom Data namespace.

Problem it solves

Booking a visit that needs a room means coordinating two calendars at once: the provider's and the room's. Done by hand, staff book the patient, then separately block the room, and have to remember to free the room if the appointment is cancelled, which is easy to miss and leads to double-booked or phantom-held rooms. This plugin books the provider and the room together and deletes the linked room event automatically when the patient appointment is cancelled.

Who it's for

Front-desk and scheduling staff at clinics where visits consume a shared physical resource, such as procedure rooms, infusion chairs, or imaging suites. It fits practices that need provider and room availability reconciled in a single booking step, including pediatric, specialty, and multi-room primary care groups.

How to install

canvas install scheduling_with_rooms

The SCHEDULABLE_STAFF_ROLES and SCHEDULE_DURATIONS variables must be set in plugin settings.

Components

Component Purpose
applications/scheduling_with_rooms_app.py SchedulingApplication — replaces the built-in scheduling modal
applications/global_panel_app.py Global panel button — opens an empty scheduling modal
applications/scheduling_admin_app.py Provider-menu admin app for the visit-type/room matrix
api/scheduling_api.py Patient/provider/slot/booking endpoints
api/scheduling_admin_api.py Admin endpoints for visit-type configuration
api/calendar.py, api/events.py Provider calendar + availability event endpoints
handlers/rfv_origination.py Originates the RFV command on APPOINTMENT_CREATED
handlers/rr_event_origination.py Creates the linked room ScheduleEvent on APPOINTMENT_CREATED
handlers/appointment_cascade.py Deletes the linked room ScheduleEvent when its parent is cancelled
handlers/availability_web_app.py Serves the availability manager UI
models/ CustomModels: visit-type durations, room mappings, concurrent limits
utils/scheduling_context.py Turns the scheduling launch context into modal prefill data
utils/patient_timezone.py Reads the preferredSchedulingTimezone PatientSetting
utils/room_link.py Records which room a visit holds, as note metadata
utils/scheduling_logic.py Slot generation, plus the bulk prefetches the month view needs
utils/calendar_availability.py Availability windows from Clinic/Administrative calendars
utils/theming.py Emits the CSS custom properties the stylesheets consume
utils/staff_lookup.py Resolves providers and rooms, and parses the roles variable
utils/rfv_cache.py, utils/rr_event_cache.py Hand booking intent from /book to the APPOINTMENT_CREATED handlers
static/scheduling_modal.css, static/scheduling_admin.css Stylesheets, served as cacheable assets

Scheduling entry points

SchedulingWithRoomsApp subclasses SchedulingApplication, so installing this plugin routes every scheduling action in Canvas through this modal — no separate launcher to click. Each surface hands over a different slice of context, which utils/scheduling_context.py resolves into the entity objects the modal pre-selects:

Origin Surface Mode Pre-populated from context
schedule_page New appointment from the schedule page schedule date
patient_chart New appointment from a patient chart schedule patient (locked), date
calendar Drag-to-create on the calendar schedule provider, location, date + slot, duration
calendar_reschedule Reschedule from the calendar reschedule appointment, provider, visit type, duration
note_reschedule Reschedule from within a note reschedule appointment, note, patient, visit type

One panel button sits alongside those Canvas-driven entry points: applications/global_panel_app.py (global scope, opens an empty modal), for customers who reach scheduling from their own landing page rather than Canvas's schedule page. It tags its launch with a non-SDK origin=global_panel, which is how the modal knows to close itself after a successful booking — there's no page underneath it expecting to be returned to. Canvas's own origins stay open and refresh their slots instead, so a scheduler can book again without relaunching; reschedules always close, since re-submitting would just move the appointment again.

There's deliberately no patient-chart panel button — the patient_chart origin above already covers that surface, with the patient resolved and locked. Both launchers build their URL with scheduling_context.modal_url(), so they share one cache-bust value and one set of query-param names.

Anything the surface didn't send is backfilled off the appointment being rescheduled — visit type, location, provider, patient, and duration all come from there. A prefilled duration stays selectable even when it isn't one of the visit type's configured choices, so a reschedule keeps its length.

In reschedule mode the modal moves the existing appointment: /book with an appointment_id issues Appointment.reschedule() (not an update, so Canvas records the move via appointment_rescheduled_from) and ScheduleEvent.reschedule() for the room. Because APPOINTMENT_CREATED doesn't fire for a move, those effects are emitted inline rather than going through handlers/rr_event_origination.py.

ScheduleEvent.reschedule() nulls parent_appointment_id, so from the second reschedule onward the room event can't be found through children. The room a visit holds is therefore also recorded as note metadata (utils/room_link.py) — the note is the only identifier stable across a reschedule chain — and the event is recovered from that when the parent link is gone.

Changing the visit type during a reschedule is not applied to the existing appointment.

Availability manager

Open the Scheduling Admin app from the provider menu and switch to the Manage Availability tab. Staff use this view to define when each provider and room is bookable; the slot and /book endpoints intersect these events with existing appointments to compute openings.

Calendar type Effect
Available (Clinic) Time is bookable
Busy (Administrative) Time is blocked off (breaks, meetings, OOO, etc.)

Events on each calendar may be one-off or recurring (daily/weekly), and each event can be restricted to a list of note types — a "well-child only" window will not surface for a follow-up visit type. The tab is served by handlers/availability_web_app.py and embedded as an iframe in the Scheduling Admin page; events are managed via api/events.py (GET/POST/PATCH/DELETE) and calendars via api/calendar.py.

Rooms are modeled as Staff with the RR (Room Resource) role and are managed in the same UI through a separate "Rooms" picker; the booking flow lands the room ScheduleEvent on the room's calendar and consumes one of its Available windows. The SCHEDULABLE_STAFF_ROLES variable controls who appears in the provider picker; RR is always unioned in for rooms.

Note types are version-controlled

Updating a note type deprecates the old row rather than editing it, so existing notes stay bound to the version they were written against. That means many rows share one id — 24 across 5 codes on one instance — and only one is active.

Every NoteType query here therefore filters is_active=True. Without it, .filter(id=...).first() returns an arbitrary version: a renamed type resolves to its old name, and a flag like allow_custom_title can read differently than the row you meant. The SDK's own ScheduleEvent validation re-resolves note_type_id without that filter, which is why room events carry no description — see _reschedule_room_events.

The one place that intentionally looks beyond active rows is the availability manager's noteTypeNames map, so an event referencing a retired type still renders a name. It orders by is_active so the current name wins.

Required variables

Variable Purpose
SCHEDULABLE_STAFF_ROLES Comma-separated list of role codes treated as schedulable
SCHEDULE_DURATIONS Default appointment-duration list (minutes), comma-separated/JSON

The BRAND_* variables are optional theming overrides (see utils/theming.py). No credentials are needed — the plugin reads everything through the SDK data models and makes no outbound HTTP calls.

Patient timezone

The modal renders slot times in the patient's timezone. The authoritative value is the preferredSchedulingTimezone PatientSetting row, read via utils/patient_timezone.py. It's fetched once, by /patient-timezone, when a patient is actually selected — search results carry the cheaper Patient.last_known_timezone instead, and the browser's own timezone is the final fallback.

How the room event is linked to the appointment

On booking, /book stashes the room intent in the plugin cache and RREventOrigination creates the room ScheduleEvent on APPOINTMENT_CREATED, setting parent_appointment_id to the patient appointment. The same handler records the chosen room as note metadata via utils/room_link.py.

Two links, because neither is sufficient alone:

Link Set by Survives a reschedule?
parent_appointment_id on the room event Canvas, at create No — ScheduleEvent.reschedule() nulls it
scheduling_with_rooms:room_staff_key note metadata this plugin Yes — the note is stable across the whole chain

Both the reschedule path and AppointmentCascadeHandler locate the room event through the same helper, room_link.find_room_events(), so they can't disagree about which room a visit holds. Cancelling an appointment releases its room whether or not it has ever been rescheduled.

parent_appointment_id cannot be repaired after the fact — the SDK rejects it outside a create ("parent_appointment_id can only be set when creating an appointment") — which is why the note metadata exists rather than the link being restored.

No reschedule guard is needed in the cascade: Canvas emits APPOINTMENT_UPDATED, not APPOINTMENT_CANCELED, for the appointment a reschedule supersedes.

Important Note!

CANVAS_MANIFEST.json is used when installing your plugin. Update it if you add, remove, or rename handlers, applications, or variables.