Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
1c6e032
doc: Add PRD for vital signs chart with openEHR transparency
claude Jan 3, 2026
9a3a19a
feat: Implement vital signs chart with openEHR transparency
claude Jan 4, 2026
fce9ddf
fix: Update pulse archetype CKM link to non-deprecated version
claude Jan 4, 2026
78af59d
fix: Correct encounter archetype CKM link
claude Jan 4, 2026
c994dc5
feat: Require encounter_id when recording vital signs
claude Jan 4, 2026
8e5db61
feat: Add openEHR template management infrastructure
claude Jan 4, 2026
6ab2b5c
feat: Use IDCR Vital Signs template from Ripple-openEHR
claude Jan 4, 2026
a46b042
docs: Update PRD and ADR with actual template implementation
claude Jan 4, 2026
0f05221
build: specify pnpm as the package manager in package.json
platzhersh Jan 4, 2026
0cbc32c
docs: add prisma migrate deploy command to API development instructions
platzhersh Jan 4, 2026
703c8e0
fix: Correct template upload Content-Type and Accept headers
claude Jan 4, 2026
82a1e93
feat: Improve template registration logging
claude Jan 4, 2026
1d3ec29
feat: add basic logging configuration
platzhersh Jan 4, 2026
7bf194d
fix: Update vital signs EHRBase composition structure, archetype vers…
platzhersh Jan 4, 2026
f5798c2
chore: Implement vital signs date validation and update EHRBase templ…
platzhersh Jan 4, 2026
4340783
fix: Add checks for `reading.id` before fetching compositions or swit…
platzhersh Jan 4, 2026
58792a6
chore: Add dedicated EHRbase client methods for formatted composition…
platzhersh Jan 4, 2026
378e145
refactor: Standardize datetime usage to be timezone-aware and add a c…
platzhersh Jan 4, 2026
deb0158
fix: Robustify vital signs input handling and openEHR metadata panel …
platzhersh Jan 4, 2026
2465817
feat: Add error handling and retry for encounter loading in Record Vi…
platzhersh Jan 4, 2026
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ pnpm dlx shadcn-vue@latest init

# 5. Run development
# Terminal 1 (API):
cd api && source .venv/bin/activate && uvicorn src.main:app --reload --port 8000
cd api && source .venv/bin/activate && prisma migrate deploy && uvicorn src.main:app --reload --port 8000

# Terminal 2 (Web):
cd web && pnpm dev
Expand Down
3 changes: 3 additions & 0 deletions api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]

[tool.ruff.lint.flake8-bugbear]
extend-immutable-calls = ["fastapi.Query", "fastapi.Depends", "fastapi.Path"]

[tool.mypy]
python_version = "3.11"
# Enable important checks while being pragmatic about strictness
Expand Down
118 changes: 118 additions & 0 deletions api/scripts/upload_templates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""Upload openEHR templates to EHRBase.

Usage:
python scripts/upload_templates.py [--check-only]

This script uploads all OPT templates from the templates/ directory to EHRBase.
Run this after starting EHRBase to ensure all required templates are available.
"""

import asyncio
import sys
from pathlib import Path

import httpx

# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))

from src.config import settings


async def list_templates(client: httpx.AsyncClient) -> list[str]:
"""List existing templates in EHRBase."""
response = await client.get("/openehr/v1/definition/template/adl1.4")
if response.status_code == 200:
templates = response.json()
return [t.get("template_id", "") for t in templates]
return []


async def upload_template(client: httpx.AsyncClient, template_path: Path) -> bool:
"""Upload a single template to EHRBase."""
template_content = template_path.read_text()
template_id = template_path.stem # filename without extension

print(f" Uploading {template_id}...")

response = await client.post(
"/openehr/v1/definition/template/adl1.4",
content=template_content,
headers={"Content-Type": "application/xml"},
)

if response.status_code in (200, 201, 204):
print(f" ✓ {template_id} uploaded successfully")
return True
elif response.status_code == 409:
print(f" ○ {template_id} already exists")
return True
else:
print(f" ✗ {template_id} failed: {response.status_code}")
try:
error_detail = response.json()
print(f" Error: {error_detail}")
except Exception:
print(f" Response: {response.text[:500]}")
return False


async def main(check_only: bool = False):
"""Upload all templates to EHRBase."""
templates_dir = Path(__file__).parent.parent / "templates"

if not templates_dir.exists():
print(f"Templates directory not found: {templates_dir}")
sys.exit(1)

template_files = list(templates_dir.glob("*.opt"))
if not template_files:
print("No .opt template files found")
sys.exit(0)

print(f"Found {len(template_files)} template(s)")
print(f"EHRBase URL: {settings.ehrbase_url}")
print()

auth = None
if settings.ehrbase_user and settings.ehrbase_password:
auth = httpx.BasicAuth(settings.ehrbase_user, settings.ehrbase_password)

async with httpx.AsyncClient(base_url=settings.ehrbase_url, auth=auth) as client:
# Check EHRBase connectivity
try:
response = await client.get("/ehrbase/rest/status")
if response.status_code != 200:
print(f"EHRBase not ready: {response.status_code}")
sys.exit(1)
print("EHRBase is ready")
except httpx.ConnectError:
print("Cannot connect to EHRBase")
sys.exit(1)

# List existing templates
existing = await list_templates(client)
print(f"Existing templates: {len(existing)}")
for t in existing:
print(f" - {t}")
print()

if check_only:
print("Check only mode - not uploading")
return

# Upload templates
print("Uploading templates...")
success_count = 0
for template_path in template_files:
if await upload_template(client, template_path):
success_count += 1

print()
print(f"Uploaded {success_count}/{len(template_files)} templates")


if __name__ == "__main__":
check_only = "--check-only" in sys.argv
asyncio.run(main(check_only))
10 changes: 8 additions & 2 deletions api/src/ehrbase/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,16 @@ async def upload_template(self, template_content: str) -> dict[str, Any]:
response = await client.post(
"/openehr/v1/definition/template/adl1.4",
content=template_content,
headers={"Content-Type": "application/XML"}
headers={
"Content-Type": "application/xml",
"Accept": "application/json, application/xml",
}
)
response.raise_for_status()
return response.json()
# EHRBase may return empty response or XML on success
if response.headers.get("content-type", "").startswith("application/json"):
return response.json()
return {"status": "uploaded"}
Comment on lines +148 to +151
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the context around lines 127-130 in client.py
cat -n api/src/ehrbase/client.py | sed -n '115,145p'

Repository: platzhersh/open-cis

Length of output: 1135


🌐 Web query:

EHRBase API template upload POST /openehr/v1/definition/template/adl1.4 response format documentation

💡 Result:

POST /openehr/v1/definition/template/adl1.4 — response format (summary)

  • Success: 201 Created. Server SHOULD return the server-assigned template_id in the Location response header (optionally with version). An ETag header MAY be present. Depending on the Prefer header the response body is either empty (minimal) or contains the full template representation (OPERATIONAL_TEMPLATE in XML/JSON). Errors: 400 for invalid content, 409 if a template with same id/version exists. (specifications.openehr.org)

Citations:


Consider handling the actual response data instead of a synthetic fallback.

Per OpenEHR API specification, the template upload endpoint correctly returns 201 Created with response body that is either empty (minimal preference) or contains the template representation in XML or JSON depending on content negotiation and Prefer header. The defensive check for application/json content-type is appropriate.

However, the fallback {"status": "uploaded"} is a synthetic response not returned by the server. When the response body is empty or XML, return the actual response data or relevant headers (the Location header contains the server-assigned template_id), rather than a constructed dict that doesn't represent the actual server response.

🤖 Prompt for AI Agents
In api/src/ehrbase/client.py around lines 127 to 130, the code returns a
synthetic {"status": "uploaded"} when the server response is empty or non-JSON;
replace this fallback with the actual response data and relevant headers: if
Content-Type is application/json return response.json(); if Content-Type is
application/xml or text/xml return the raw response.text (or parse to an XML
object if desired); if body is empty, return a minimal representation containing
response.status_code and any relevant headers (e.g. Location for template_id)
instead of a fabricated status field; ensure no synthetic dict is returned so
callers can inspect real server output and headers.


async def close(self):
if self._client:
Expand Down
45 changes: 39 additions & 6 deletions api/src/ehrbase/queries.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,41 @@
"""Common AQL queries for clinical data retrieval."""

# Get all vital signs for a patient
# Get all vital signs (blood pressure + pulse) for a patient
VITAL_SIGNS_QUERY = """
SELECT
c/uid/value as composition_id,
o/data[at0001]/events[at0006]/data[at0003]/items[at0004]/value/magnitude as systolic,
o/data[at0001]/events[at0006]/data[at0003]/items[at0005]/value/magnitude as diastolic,
o/data[at0001]/events[at0006]/time/value as time
c/context/start_time/value as recorded_at,
bp/data[at0001]/events[at0006]/data[at0003]/items[at0004]/value/magnitude as systolic,
bp/data[at0001]/events[at0006]/data[at0003]/items[at0005]/value/magnitude as diastolic,
pulse/data[at0002]/events[at0003]/data[at0001]/items[at0004]/value/magnitude as pulse_rate
FROM EHR e
CONTAINS COMPOSITION c
CONTAINS OBSERVATION o[openEHR-EHR-OBSERVATION.blood_pressure.v2]
CONTAINS (
OBSERVATION bp[openEHR-EHR-OBSERVATION.blood_pressure.v2] OR
OBSERVATION pulse[openEHR-EHR-OBSERVATION.pulse.v2]
)
WHERE e/ehr_id/value = $ehr_id
ORDER BY o/data[at0001]/events[at0006]/time/value DESC
ORDER BY c/context/start_time/value DESC
"""

# Get vital signs for a patient within a date range
VITAL_SIGNS_DATE_RANGE_QUERY = """
SELECT
c/uid/value as composition_id,
c/context/start_time/value as recorded_at,
bp/data[at0001]/events[at0006]/data[at0003]/items[at0004]/value/magnitude as systolic,
bp/data[at0001]/events[at0006]/data[at0003]/items[at0005]/value/magnitude as diastolic,
pulse/data[at0002]/events[at0003]/data[at0001]/items[at0004]/value/magnitude as pulse_rate
FROM EHR e
CONTAINS COMPOSITION c
CONTAINS (
OBSERVATION bp[openEHR-EHR-OBSERVATION.blood_pressure.v2] OR
OBSERVATION pulse[openEHR-EHR-OBSERVATION.pulse.v2]
)
WHERE e/ehr_id/value = $ehr_id
AND c/context/start_time/value >= $from_date
AND c/context/start_time/value <= $to_date
ORDER BY c/context/start_time/value DESC
"""

# Get all encounters for a patient
Expand All @@ -37,3 +61,12 @@
CONTAINS INSTRUCTION i[openEHR-EHR-INSTRUCTION.medication_order.v3]
WHERE e/ehr_id/value = $ehr_id
"""

# Count vital signs for a patient
VITAL_SIGNS_COUNT_QUERY = """
SELECT COUNT(c/uid/value) as count
FROM EHR e
CONTAINS COMPOSITION c
WHERE e/ehr_id/value = $ehr_id
AND c/archetype_details/template_id/value = 'open-cis.vital-signs.v1'
"""
81 changes: 81 additions & 0 deletions api/src/ehrbase/templates.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
"""Template management for EHRBase."""

import logging
from pathlib import Path
from typing import Any

from src.ehrbase.client import ehrbase_client

logger = logging.getLogger(__name__)

# Template files that must be registered in EHRBase
# Filename must be "{template_id}.opt"
REQUIRED_TEMPLATES = [
"IDCR - Vital Signs Encounter.v1",
]


async def list_templates() -> list[dict[str, Any]]:
"""List all available operational templates."""
Expand All @@ -24,3 +34,74 @@ async def get_template_example(template_id: str, format: str = "FLAT") -> dict[s
)
response.raise_for_status()
return response.json()


async def get_registered_template_ids() -> list[str]:
"""Get list of template IDs registered in EHRBase."""
try:
templates = await list_templates()
return [t.get("template_id", "") for t in templates]
except Exception as e:
logger.warning(f"Failed to list EHRBase templates: {e}")
return []


async def upload_template_file(template_id: str, template_content: str) -> bool:
"""Upload a single template to EHRBase."""
try:
await upload_template(template_content)
logger.info(f"Template {template_id} uploaded successfully")
return True
except Exception as e:
error_msg = str(e)
# 409 means template already exists - that's OK
if "409" in error_msg:
logger.info(f"Template {template_id} already exists")
return True
logger.error(f"Failed to upload template {template_id}: {e}")
return False


async def ensure_templates_registered() -> dict[str, bool]:
"""
Ensure all required templates are registered in EHRBase.

Called during API startup. Returns a dict mapping template_id to success status.
"""
results: dict[str, bool] = {}

# Find templates directory (relative to this file)
templates_dir = Path(__file__).parent.parent.parent / "templates"

if not templates_dir.exists():
logger.warning(f"Templates directory not found: {templates_dir}")
return results

# Check what's already registered
try:
existing = await get_registered_template_ids()
logger.info(f"EHRBase has {len(existing)} registered template(s)")
except Exception as e:
logger.warning(f"Could not connect to EHRBase to check templates: {e}")
return results

# Upload any missing required templates
for template_id in REQUIRED_TEMPLATES:
template_file = templates_dir / f"{template_id}.opt"

if not template_file.exists():
logger.warning(f"Template file not found: {template_file}")
results[template_id] = False
continue

if template_id in existing:
logger.info(f"Template {template_id} already registered")
results[template_id] = True
continue

# Read and upload
logger.info(f"Uploading template {template_id}...")
template_content = template_file.read_text()
results[template_id] = await upload_template_file(template_id, template_content)

return results
12 changes: 12 additions & 0 deletions api/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from src.config import settings
from src.db.client import prisma
from src.ehrbase.client import ehrbase_client
from src.ehrbase.templates import ensure_templates_registered
from src.encounters.router import router as encounters_router
from src.observations.router import router as observations_router
from src.patients.router import router as patients_router
Expand All @@ -24,6 +25,17 @@ async def lifespan(app: FastAPI):
logger.info("Database connected successfully")
except Exception as e:
logger.error(f"Failed to connect to database: {e}")

# Ensure openEHR templates are registered in EHRBase
try:
template_results = await ensure_templates_registered()
if template_results:
for template_id, success in template_results.items():
status = "registered" if success else "FAILED"
logger.info(f"Template {template_id}: {status}")
except Exception as e:
logger.warning(f"Could not ensure templates are registered: {e}")

yield
# Shutdown
if prisma.is_connected():
Expand Down
Loading
Loading