Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions extensions/patient-panel/patient_panel/CANVAS_MANIFEST.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"sdk_version": "0.154.1",
"plugin_version": "2.0.1",
"plugin_version": "2.1.0",
"name": "patient_panel",
"description": "Patient panel management dashboard with filtering, tasks, gaps, and clinical notes",
"url_permissions": [],
Expand Down Expand Up @@ -213,9 +213,6 @@
"HIGHLIGHT_THRESHOLD_DAYS_RED",
"PAGE_SIZE",
"INSURANCES",
"FHIR_CLIENT_ID",
"FHIR_CLIENT_SECRET",
"CANVAS_INSTANCE_URL",
"PANEL_CONFIG",
"INSTANCE_TIMEZONE",
"FLAG_COLOR_LABELS",
Expand Down
3 changes: 0 additions & 3 deletions extensions/patient-panel/patient_panel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,6 @@ Available built-in column keys: `patient`, `care_team`, `last_visit`, `next_visi
| `HIGHLIGHT_THRESHOLD_DAYS_YELLOW` | No | Days threshold for yellow highlight |
| `HIGHLIGHT_THRESHOLD_DAYS_RED` | No | Days threshold for red highlight |
| `INSURANCES` | No | Insurance logo mappings |
| `FHIR_CLIENT_ID` | No | FHIR client ID for patient photos |
| `FHIR_CLIENT_SECRET` | No | FHIR client secret for patient photos |
| `CANVAS_INSTANCE_URL` | No | Canvas instance URL for FHIR API |
| `INSTANCE_TIMEZONE` | No | IANA TZ name (e.g. `America/New_York`) used when the logged-in staff has no `last_known_timezone`. Defaults to `UTC`. |
| `FLAG_COLOR_LABELS` | No | JSON dict overriding the dropdown labels for the three flag colors, e.g. `{"red": "Urgent", "yellow": "Follow-up", "green": "On track"}`. Missing keys fall back to capitalized color names. |
| `METADATA_FIELDS` | No | JSON list of extra patient-profile fields driven by the `PatientMetadataFields` handler. Each entry is `{"key", "label", "type": "TEXT" \| "SELECT" \| "DATE", "required", "editable", "options"?}`. Same `key` values are referenced by `type: "metadata"` columns in `PANEL_CONFIG` and gate the inline `POST /<patient_id>/metadata/<key>` edit endpoint (only `editable: true` keys can be written). |
Expand Down
189 changes: 4 additions & 185 deletions extensions/patient-panel/patient_panel/api/panel_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
from typing import Any

import arrow
import requests
from canvas_sdk.caching.plugins import get_cache
from canvas_sdk.effects import Effect
from canvas_sdk.effects.patient import Patient as PatientEffect
Expand All @@ -15,7 +14,6 @@
from canvas_sdk.handlers.simple_api import SimpleAPI, api
from canvas_sdk.handlers.simple_api.security import StaffSessionAuthMixin
from canvas_sdk.templates import render_to_string
from canvas_sdk.utils import Http
from canvas_sdk.v1.data import Patient
from canvas_sdk.v1.data.care_team import CareTeamMembership, CareTeamMembershipStatus
from canvas_sdk.v1.data.patient import (
Expand Down Expand Up @@ -51,11 +49,6 @@
get_referrals_details,
get_task_comments,
)
from patient_panel.services.fhir_photo import (
build_patient_fhir_url,
build_token_url,
parse_photo_response,
)
from patient_panel.services.formatting import format_local
from patient_panel.services.lookups import (
get_facilities,
Expand Down Expand Up @@ -92,25 +85,6 @@
# so browsers fetch fresh CSS/JS.
_CACHE_BUST = str(int(datetime.now(timezone.utc).timestamp()))

# Patient photos are fetched one outbound FHIR call PER patient. The SDK Http
# client uses a 30s timeout; on a 100-row page that's ~100 concurrent requests,
# and if the FHIR host is slow/unreachable each one pins a plugin-runner worker
# for 30s — exhausting the worker pool and 502-ing the whole panel (incl /table).
# Photos are non-critical decoration, so fetch them with a short timeout and
# fail fast to the default avatar instead of blocking a worker.
_PHOTO_FETCH_TIMEOUT_SECONDS = 4


class _ShortTimeoutHttp:
"""Minimal requests wrapper with a bounded timeout, for the photo path only.

Mirrors the `.get(url, headers=...)` surface that fhir_photo.parse_photo_response
expects, but caps every call at `_PHOTO_FETCH_TIMEOUT_SECONDS` (the SDK Http
hardcodes 30s and offers no override)."""

def get(self, url: str, headers: dict[str, str] | None = None) -> requests.Response:
return requests.get(url, headers=headers, timeout=_PHOTO_FETCH_TIMEOUT_SECONDS)


def _is_uuid(value: str) -> bool:
"""Return True if `value` parses as a UUID — guards UUID-typed filters."""
Expand Down Expand Up @@ -150,12 +124,9 @@ class PatientPanelAPI(StaffSessionAuthMixin, SimpleAPI):
# (messages, letters, C-CDA/data imports).
LAST_VISIT_EXCLUDED_NOTE_TYPES = ("message", "letter", "data", "ccda")

_fhir_token_cache: dict = {}
_FHIR_TOKEN_TTL_MINUTES = 55

# staff_id → resolved display timezone. Sandbox forbids instance-dict
# mutation, so we cache at class level via whole-dict replacement (same
# pattern as `_fhir_token_cache`). Bounded by staff count per instance.
# mutation, so we cache at class level via whole-dict replacement.
# Bounded by staff count per instance.
_display_tz_cache: dict[str, str] = {}


Expand Down Expand Up @@ -684,78 +655,8 @@ def view_clinical_notes(self) -> list[Response | Effect]:
)
]

# ── Photos ────────────────────────────────────────────────────────

_PHOTO_CACHE_TTL_SECONDS = 4 * 3600 # 4 hours — for successful fetches
_PHOTO_MISSING_CACHE_TTL_SECONDS = 60 # short TTL so uploads show within a minute

@api.get("/<patient_id>/photo")
def get_patient_photo(self) -> list[Response | Effect]:
"""Serve patient photo from FHIR API.

Photo bytes are cached per patient for `_PHOTO_CACHE_TTL_SECONDS`.
A sentinel `{"missing": True}` is cached briefly (60s) when FHIR
returns no photo so repeated cold renders don't hammer FHIR, while
still letting fresh uploads appear quickly.

Pass `?nocache=1` to bypass the cache entirely and delete the
existing key — useful after uploading a new photo or fixing a bug
that previously cached a missing sentinel.
"""
patient_id = self.request.path_params["patient_id"]
cache = get_cache()
cache_key = f"patient_photo_{patient_id}"

bypass = self.request.query_params.get("nocache") == "1"
if bypass:
cache.delete(cache_key)
log.info(f"[photo] {patient_id}: cache bypassed via ?nocache=1")
else:
cached = cache.get(cache_key)
if cached is not None:
if cached.get("missing"):
log.info(f"[photo] {patient_id}: serving default (missing cached)")
# Cache the default-avatar fallback in the BROWSER for the
# same short window as the server-side "missing" sentinel.
# Without this the browser revalidates every render, so a
# 100-row page with no FHIR photos re-fires 100 requests on
# every sort/re-render. A short TTL still lets a freshly
# uploaded photo appear within ~a minute.
return [Response(status_code=302, headers={"Location": self.DEFAULT_AVATAR, "Cache-Control": f"public, max-age={self._PHOTO_MISSING_CACHE_TTL_SECONDS}"})]
return [
Response(
content=cached["data"],
status_code=200,
content_type=cached["content_type"],
headers={"Cache-Control": "public, max-age=3600"},
)
]

token = self._get_fhir_token()
if not token:
# Short browser cache so a transient token-missing render doesn't
# re-fire every patient's photo request on the next re-render.
return [Response(status_code=302, headers={"Location": self.DEFAULT_AVATAR, "Cache-Control": f"public, max-age={self._PHOTO_MISSING_CACHE_TTL_SECONDS}"})]

photo_data = self._fetch_patient_photo_data(patient_id, token)
if photo_data:
content_type, data = photo_data
cache.set(
cache_key,
{"content_type": content_type, "data": data},
timeout_seconds=self._PHOTO_CACHE_TTL_SECONDS,
)
return [
Response(
content=data,
status_code=200,
content_type=content_type,
headers={"Cache-Control": "public, max-age=3600"},
)
]

cache.set(cache_key, {"missing": True}, timeout_seconds=self._PHOTO_MISSING_CACHE_TTL_SECONDS)
return [Response(status_code=302, headers={"Location": self.DEFAULT_AVATAR, "Cache-Control": f"public, max-age={self._PHOTO_MISSING_CACHE_TTL_SECONDS}"})]
# Patient photos are read directly from the DB via `patient.photo_url`
# (see services.serialization) — no per-row endpoint or FHIR round-trip.

# ── Flags ────────────────────────────────────────────────────────

Expand Down Expand Up @@ -1037,88 +938,6 @@ def _parse_threshold(val: Any, default: int) -> int:
"insurances_logos": insurances_logos,
}

def _get_fhir_token(self) -> str | None:
"""Get OAuth token for FHIR API calls. Cached with 55-minute TTL."""
cache = PatientPanelAPI._fhir_token_cache
if cache.get("token") and cache.get("expires") and arrow.now() < cache["expires"]:
return str(cache["token"])

client_id = self.secrets.get("FHIR_CLIENT_ID")
client_secret = self.secrets.get("FHIR_CLIENT_SECRET")
instance_url = self.secrets.get("CANVAS_INSTANCE_URL")

if not client_id or not client_secret or not instance_url:
return None

token_url = build_token_url(instance_url)

try:
http = Http()
response = http.post(
token_url,
data={
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
"scope": "system/Patient.read system/Practitioner.read",
},
)
if response.status_code == 200:
token: str | None = response.json().get("access_token")
if token:
PatientPanelAPI._fhir_token_cache = {
"token": token,
"expires": arrow.now().shift(minutes=self._FHIR_TOKEN_TTL_MINUTES),
}
return token
else:
log.error(f"FHIR token error: status {response.status_code}")
except Exception:
log.exception("FHIR token fetch failed")

return None

def _fetch_patient_photo_data(
self, patient_id: str, token: str
) -> tuple[str, bytes] | None:
"""Fetch patient photo data from FHIR API.

Logs the decision branch so production traces show *why* the default
avatar fell through — empty photo array vs HTTP error vs missing
data/url field.
"""
instance_url = self.secrets.get("CANVAS_INSTANCE_URL")
if not instance_url:
log.info(f"[photo] {patient_id}: CANVAS_INSTANCE_URL secret missing")
return None

fhir_url = build_patient_fhir_url(instance_url, patient_id)

try:
# Short-timeout client (not SDK Http's 30s): a per-row photo must
# fail fast to the default avatar rather than pin a worker — see
# _ShortTimeoutHttp. Bounds both this read and the presigned-URL hop
# inside parse_photo_response.
http = _ShortTimeoutHttp()
response = http.get(
fhir_url,
headers={"Authorization": f"Bearer {token}"},
)
if response.status_code != 200:
body_snippet = (response.text or "")[:200]
log.error(
f"[photo] {patient_id}: FHIR GET {response.status_code} — {body_snippet}"
)
return None

return parse_photo_response(
response.json(), token, http, patient_id
)
except Exception:
log.exception(f"[photo] {patient_id}: fetch failed")

return None

def _display_tz(self) -> str:
"""Resolve the display timezone for date formatting.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,19 @@

from typing import Any

from canvas_sdk.caching.plugins import get_cache
from canvas_sdk.effects import Effect
from canvas_sdk.events import Event, EventType
from canvas_sdk.handlers import BaseHandler
from logger import log

from patient_panel.services.stats_recompute import recompute_stats_for_patient_uuid

# Coalesce repeated recomputes for the same patient: one note/task save cascades
# several events, each of which would otherwise rerun the full recompute. The
# nightly reconcile cron is the correctness backstop, so a skipped one self-heals.
_RECOMPUTE_DEBOUNCE_SECONDS = 30


def _patient_uuid_from_context(event: Any) -> str | None:
ctx = getattr(event, "context", None) or {}
Expand All @@ -42,10 +48,25 @@ class _PanelStatsMixin:
def _patient_uuid(self) -> str | None:
return _patient_uuid_from_context(self.event)

def _recently_recomputed(self, uuid: str) -> bool:
"""True if this patient was recomputed within the debounce window.

Cache failures degrade to False (recompute proceeds) so a cache outage
can never suppress updates."""
try:
cache = get_cache()
key = f"panel_stats_recompute_{uuid}"
if cache.get(key):
return True
cache.set(key, True, timeout_seconds=_RECOMPUTE_DEBOUNCE_SECONDS)
except Exception:
return False
return False

def compute(self) -> list[Effect]:
try:
uuid = self._patient_uuid()
if uuid:
if uuid and not self._recently_recomputed(uuid):
recompute_stats_for_patient_uuid(uuid)
except Exception:
log.exception("[panel_stats] recompute failed for %s", self.__class__.__name__)
Expand Down
80 changes: 0 additions & 80 deletions extensions/patient-panel/patient_panel/services/fhir_photo.py

This file was deleted.

Loading