pylantir 0.2.3__py3-none-any.whl → 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pylantir/api_server.py +13 -9
- pylantir/cli/run.py +307 -41
- pylantir/config/calpendo_config_example.json +65 -0
- pylantir/config/mwl_config.json +3 -1
- pylantir/data_sources/__init__.py +84 -0
- pylantir/data_sources/base.py +117 -0
- pylantir/data_sources/calpendo_plugin.py +702 -0
- pylantir/data_sources/redcap_plugin.py +367 -0
- pylantir/db_setup.py +3 -0
- pylantir/models.py +3 -0
- pylantir/populate_db.py +6 -3
- pylantir/redcap_to_db.py +128 -81
- {pylantir-0.2.3.dist-info → pylantir-0.3.1.dist-info}/METADATA +316 -33
- pylantir-0.3.1.dist-info/RECORD +25 -0
- pylantir-0.2.3.dist-info/RECORD +0 -20
- {pylantir-0.2.3.dist-info → pylantir-0.3.1.dist-info}/WHEEL +0 -0
- {pylantir-0.2.3.dist-info → pylantir-0.3.1.dist-info}/entry_points.txt +0 -0
- {pylantir-0.2.3.dist-info → pylantir-0.3.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Calpendo Data Source Plugin
|
|
3
|
+
|
|
4
|
+
This module implements the DataSourcePlugin interface for Calpendo WebDAV API integration.
|
|
5
|
+
Fetches MRI/EEG scanner bookings from Calpendo and transforms them into DICOM worklist entries.
|
|
6
|
+
|
|
7
|
+
Version: 1.0.0
|
|
8
|
+
Constitutional Compliance:
|
|
9
|
+
- Minimalist Dependencies: requests (HTTP client), pytz (timezone handling)
|
|
10
|
+
- Healthcare Data Integrity: Full audit trail, change detection via hashing
|
|
11
|
+
- Operational Observability: Structured logging at all levels
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import logging
|
|
16
|
+
import re
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
from urllib.parse import quote
|
|
20
|
+
from datetime import datetime, timedelta
|
|
21
|
+
from typing import Dict, List, Tuple, Optional
|
|
22
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
23
|
+
import requests
|
|
24
|
+
import pytz
|
|
25
|
+
import gc
|
|
26
|
+
|
|
27
|
+
from .base import DataSourcePlugin, PluginFetchError, PluginConfigError
|
|
28
|
+
|
|
29
|
+
lgr = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CalendoPlugin(DataSourcePlugin):
|
|
33
|
+
"""
|
|
34
|
+
Calpendo data source plugin.
|
|
35
|
+
|
|
36
|
+
Connects to Calpendo WebDAV API and fetches scanner booking entries with
|
|
37
|
+
regex-based field extraction, timezone conversion, and change detection.
|
|
38
|
+
|
|
39
|
+
Configuration Requirements:
|
|
40
|
+
- base_url: Calpendo server URL
|
|
41
|
+
- resources: List of resource names to sync (e.g., ["3T Diagnostic", "EEG"])
|
|
42
|
+
- field_mapping: Dict mapping Calpendo fields to WorklistItem fields
|
|
43
|
+
|
|
44
|
+
Environment Variables:
|
|
45
|
+
- CALPENDO_USERNAME: Calpendo API username
|
|
46
|
+
- CALPENDO_PASSWORD: Calpendo API password
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
# Status mapping: Calpendo status → DICOM procedure step status
|
|
50
|
+
STATUS_MAPPING = {
|
|
51
|
+
"Approved": "SCHEDULED",
|
|
52
|
+
"In Progress": "IN_PROGRESS",
|
|
53
|
+
"Completed": "COMPLETED",
|
|
54
|
+
"Cancelled": "DISCONTINUED",
|
|
55
|
+
"Pending": "SCHEDULED",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
def __init__(self):
|
|
59
|
+
super().__init__()
|
|
60
|
+
self._base_url = None
|
|
61
|
+
self._username = None
|
|
62
|
+
self._password = None
|
|
63
|
+
self._config = None
|
|
64
|
+
self._timezone = None
|
|
65
|
+
|
|
66
|
+
def validate_config(self, config: Dict) -> Tuple[bool, str]:
|
|
67
|
+
"""
|
|
68
|
+
Validate Calpendo plugin configuration.
|
|
69
|
+
|
|
70
|
+
Checks required fields, environment variables, and field_mapping structure.
|
|
71
|
+
"""
|
|
72
|
+
# Store config for later use
|
|
73
|
+
self._config = config
|
|
74
|
+
|
|
75
|
+
# Check required config fields
|
|
76
|
+
if "base_url" not in config:
|
|
77
|
+
return (False, "Missing required configuration key: base_url")
|
|
78
|
+
|
|
79
|
+
if "resources" not in config:
|
|
80
|
+
return (False, "Missing required configuration key: resources")
|
|
81
|
+
|
|
82
|
+
if not isinstance(config["resources"], list) or len(config["resources"]) == 0:
|
|
83
|
+
return (False, "resources must be a non-empty list")
|
|
84
|
+
|
|
85
|
+
if "field_mapping" not in config:
|
|
86
|
+
return (False, "Missing required configuration key: field_mapping")
|
|
87
|
+
|
|
88
|
+
# Check environment variables
|
|
89
|
+
self._username = os.getenv("CALPENDO_USERNAME")
|
|
90
|
+
self._password = os.getenv("CALPENDO_PASSWORD")
|
|
91
|
+
|
|
92
|
+
if not self._username or not self._password:
|
|
93
|
+
return (False, "CALPENDO_USERNAME and CALPENDO_PASSWORD environment variables must be set")
|
|
94
|
+
|
|
95
|
+
self._base_url = config["base_url"]
|
|
96
|
+
|
|
97
|
+
# Validate optional fields
|
|
98
|
+
lookback_multiplier = config.get("lookback_multiplier", 2)
|
|
99
|
+
if not isinstance(lookback_multiplier, (int, float)) or lookback_multiplier <= 0:
|
|
100
|
+
return (False, "lookback_multiplier must be a positive number")
|
|
101
|
+
|
|
102
|
+
allowed_studies = config.get("allowed_studies")
|
|
103
|
+
if allowed_studies is not None:
|
|
104
|
+
if not isinstance(allowed_studies, list) or not all(
|
|
105
|
+
isinstance(item, str) and item.strip() for item in allowed_studies
|
|
106
|
+
):
|
|
107
|
+
return (False, "allowed_studies must be a non-empty list of strings")
|
|
108
|
+
config["allowed_studies"] = [item.strip() for item in allowed_studies if item.strip()]
|
|
109
|
+
if not config["allowed_studies"]:
|
|
110
|
+
return (False, "allowed_studies must be a non-empty list of strings")
|
|
111
|
+
|
|
112
|
+
# Validate timezone
|
|
113
|
+
timezone_str = config.get("timezone", "America/Edmonton")
|
|
114
|
+
try:
|
|
115
|
+
self._timezone = pytz.timezone(timezone_str)
|
|
116
|
+
except Exception as e:
|
|
117
|
+
return (False, f"Invalid timezone '{timezone_str}': {e}")
|
|
118
|
+
|
|
119
|
+
# Validate field_mapping structure
|
|
120
|
+
field_mapping = config.get("field_mapping", {})
|
|
121
|
+
for target_field, mapping in field_mapping.items():
|
|
122
|
+
if isinstance(mapping, dict) and "_extract" in mapping:
|
|
123
|
+
extract_config = mapping["_extract"]
|
|
124
|
+
if "pattern" not in extract_config:
|
|
125
|
+
return (False, f"Field '{target_field}' has _extract but missing 'pattern' key")
|
|
126
|
+
|
|
127
|
+
self.logger.debug(f"Calpendo plugin validated: {len(config['resources'])} resources")
|
|
128
|
+
return (True, "")
|
|
129
|
+
|
|
130
|
+
def fetch_entries(
|
|
131
|
+
self,
|
|
132
|
+
field_mapping: Dict[str, str],
|
|
133
|
+
interval: float
|
|
134
|
+
) -> List[Dict]:
|
|
135
|
+
"""
|
|
136
|
+
Fetch worklist entries from Calpendo.
|
|
137
|
+
|
|
138
|
+
Uses rolling window sync strategy to fetch bookings modified within
|
|
139
|
+
the lookback period, applies change detection to minimize DB writes.
|
|
140
|
+
"""
|
|
141
|
+
try:
|
|
142
|
+
# Validate field mapping presence
|
|
143
|
+
if not field_mapping:
|
|
144
|
+
field_mapping = self._config.get("field_mapping", {}) if self._config else {}
|
|
145
|
+
self.logger.warning(
|
|
146
|
+
"No field_mapping provided to Calpendo plugin. "
|
|
147
|
+
"config_field_mapping_keys=%s",
|
|
148
|
+
list(field_mapping.keys()),
|
|
149
|
+
)
|
|
150
|
+
else:
|
|
151
|
+
self.logger.debug(
|
|
152
|
+
"Calpendo field_mapping keys: %s",
|
|
153
|
+
list(field_mapping.keys()),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Calculate rolling window
|
|
157
|
+
now = datetime.now(self._timezone)
|
|
158
|
+
window_mode = self._config.get("window_mode")
|
|
159
|
+
use_daily_window = self._config.get("daily_window", False)
|
|
160
|
+
|
|
161
|
+
if window_mode == "today" or use_daily_window:
|
|
162
|
+
start_time = self._timezone.localize(
|
|
163
|
+
datetime(now.year, now.month, now.day)
|
|
164
|
+
)
|
|
165
|
+
end_time = start_time + timedelta(days=1)
|
|
166
|
+
self.logger.debug(
|
|
167
|
+
"Using daily window from %s to %s",
|
|
168
|
+
start_time.isoformat(),
|
|
169
|
+
end_time.isoformat(),
|
|
170
|
+
)
|
|
171
|
+
else:
|
|
172
|
+
lookback_multiplier = self._config.get("lookback_multiplier", 2)
|
|
173
|
+
start_time = now - timedelta(seconds=interval * lookback_multiplier)
|
|
174
|
+
end_time = now + timedelta(hours=24)
|
|
175
|
+
|
|
176
|
+
self.logger.debug(
|
|
177
|
+
f"Fetching Calpendo bookings from {start_time.isoformat()} "
|
|
178
|
+
f"to {end_time.isoformat()}"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Fetch booking IDs in window
|
|
182
|
+
booking_ids = self._fetch_bookings_in_window(start_time, end_time)
|
|
183
|
+
self.logger.debug(
|
|
184
|
+
"Found %s bookings in window (%s to %s)",
|
|
185
|
+
len(booking_ids),
|
|
186
|
+
start_time.isoformat(),
|
|
187
|
+
end_time.isoformat(),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
if not booking_ids:
|
|
191
|
+
return []
|
|
192
|
+
|
|
193
|
+
# Fetch details in parallel
|
|
194
|
+
bookings = self._fetch_booking_details_parallel(booking_ids)
|
|
195
|
+
|
|
196
|
+
# Transform and filter
|
|
197
|
+
entries = []
|
|
198
|
+
for booking in bookings:
|
|
199
|
+
entry = self._transform_booking_to_entry(booking, field_mapping)
|
|
200
|
+
if entry: # Skip invalid bookings
|
|
201
|
+
entries.append(entry)
|
|
202
|
+
|
|
203
|
+
self.logger.debug(f"Transformed {len(entries)} valid worklist entries")
|
|
204
|
+
|
|
205
|
+
# Clean up
|
|
206
|
+
gc.collect()
|
|
207
|
+
|
|
208
|
+
return entries
|
|
209
|
+
|
|
210
|
+
except Exception as e:
|
|
211
|
+
self.logger.error(f"Failed to fetch Calpendo entries: {e}")
|
|
212
|
+
raise PluginFetchError(f"Calpendo fetch failed: {e}") from e
|
|
213
|
+
|
|
214
|
+
def _build_booking_query(self, start_time: datetime, end_time: datetime) -> str:
|
|
215
|
+
"""
|
|
216
|
+
Construct Calpendo WebDAV query string.
|
|
217
|
+
|
|
218
|
+
Query format: AND/OR logic with date ranges and filters
|
|
219
|
+
"""
|
|
220
|
+
# Format dates as YYYYMMDD-HHMM
|
|
221
|
+
start_str = start_time.strftime("%Y%m%d-%H%M")
|
|
222
|
+
end_str = end_time.strftime("%Y%m%d-%H%M")
|
|
223
|
+
|
|
224
|
+
# Base date range (AND) — matches example_for_calpendo.py behavior
|
|
225
|
+
query = f"AND/dateRange.start/GE/{start_str}/dateRange.start/LT/{end_str}"
|
|
226
|
+
|
|
227
|
+
# Resource filter (OR) — URL-encode resource names
|
|
228
|
+
resources = self._config.get("resources", [])
|
|
229
|
+
if resources:
|
|
230
|
+
resource_filters = "/OR" + "".join([
|
|
231
|
+
f"/resource.name/EQ/{quote(r)}" for r in resources
|
|
232
|
+
])
|
|
233
|
+
query += resource_filters
|
|
234
|
+
|
|
235
|
+
# Status filter (AND)
|
|
236
|
+
status_filter = self._config.get("status_filter")
|
|
237
|
+
if status_filter:
|
|
238
|
+
query += f"/status/EQ/{quote(status_filter)}"
|
|
239
|
+
|
|
240
|
+
self.logger.debug(f"Built query: {query}")
|
|
241
|
+
return query
|
|
242
|
+
|
|
243
|
+
def _fetch_bookings_in_window(
|
|
244
|
+
self,
|
|
245
|
+
start_time: datetime,
|
|
246
|
+
end_time: datetime
|
|
247
|
+
) -> List[int]:
|
|
248
|
+
"""Query Calpendo for booking IDs in time window."""
|
|
249
|
+
query = self._build_booking_query(start_time, end_time)
|
|
250
|
+
url = f"{self._base_url}/webdav/q/Calpendo.Booking/{query}"
|
|
251
|
+
|
|
252
|
+
auth = (self._username, self._password)
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
self.logger.debug(f"Fetching bookings from: {url}")
|
|
256
|
+
response = requests.get(url, auth=auth, timeout=30)
|
|
257
|
+
response.raise_for_status()
|
|
258
|
+
self.logger.debug(
|
|
259
|
+
"Calpendo booking list response OK (status %s)",
|
|
260
|
+
response.status_code,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
data = response.json()
|
|
264
|
+
booking_ids = [b["id"] for b in data.get("biskits", [])]
|
|
265
|
+
|
|
266
|
+
self.logger.debug(f"Fetched {len(booking_ids)} booking IDs")
|
|
267
|
+
return booking_ids
|
|
268
|
+
|
|
269
|
+
except requests.exceptions.HTTPError as e:
|
|
270
|
+
if e.response.status_code == 401:
|
|
271
|
+
raise PluginFetchError("Calpendo authentication failed") from e
|
|
272
|
+
raise PluginFetchError(f"HTTP error fetching bookings: {e}") from e
|
|
273
|
+
except Exception as e:
|
|
274
|
+
raise PluginFetchError(f"Failed to fetch bookings: {e}") from e
|
|
275
|
+
|
|
276
|
+
def _fetch_booking_details(self, booking_id: int) -> Optional[Dict]:
|
|
277
|
+
"""Fetch detailed booking information."""
|
|
278
|
+
url = f"{self._base_url}/webdav/b/Calpendo.Booking/{booking_id}"
|
|
279
|
+
auth = (self._username, self._password)
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
response = requests.get(url, auth=auth, timeout=10)
|
|
283
|
+
if response.status_code == 404:
|
|
284
|
+
self.logger.warning(f"Booking {booking_id} not found (deleted?)")
|
|
285
|
+
return None
|
|
286
|
+
response.raise_for_status()
|
|
287
|
+
self.logger.debug(
|
|
288
|
+
"Calpendo booking detail response OK (status %s) for booking %s",
|
|
289
|
+
response.status_code,
|
|
290
|
+
booking_id,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
booking = response.json()
|
|
294
|
+
|
|
295
|
+
properties = booking.get("properties") if isinstance(booking.get("properties"), dict) else {}
|
|
296
|
+
self.logger.debug(
|
|
297
|
+
"Calpendo booking payload summary for %s: biskitType=%s keys=%s properties_keys=%s title=%s properties.title=%s formattedName=%s dateRange=%s status=%s",
|
|
298
|
+
booking_id,
|
|
299
|
+
booking.get("biskitType"),
|
|
300
|
+
list(booking.keys()),
|
|
301
|
+
list(properties.keys()) if isinstance(properties, dict) else None,
|
|
302
|
+
booking.get("title"),
|
|
303
|
+
properties.get("title") if isinstance(properties, dict) else None,
|
|
304
|
+
booking.get("formattedName"),
|
|
305
|
+
properties.get("dateRange") if isinstance(properties, dict) else None,
|
|
306
|
+
properties.get("status") if isinstance(properties, dict) else booking.get("status"),
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
# Fetch operator for MRIScan
|
|
310
|
+
if booking.get("biskitType") == "MRIScan":
|
|
311
|
+
operator = self._fetch_mri_operator(booking_id)
|
|
312
|
+
if operator:
|
|
313
|
+
booking["operator"] = operator
|
|
314
|
+
|
|
315
|
+
return booking
|
|
316
|
+
|
|
317
|
+
except requests.exceptions.HTTPError as e:
|
|
318
|
+
self.logger.error(f"HTTP error fetching booking {booking_id}: {e}")
|
|
319
|
+
raise PluginFetchError(f"Booking detail fetch failed: {e}") from e
|
|
320
|
+
except Exception as e:
|
|
321
|
+
self.logger.error(f"Failed to fetch booking {booking_id}: {e}")
|
|
322
|
+
return None
|
|
323
|
+
|
|
324
|
+
def _fetch_mri_operator(self, booking_id: int) -> Optional[str]:
|
|
325
|
+
"""Retrieve operator name for MRIScan bookings."""
|
|
326
|
+
url = f"{self._base_url}/webdav/q/MRIScan/id/eq/{booking_id}?paths=Operator.name"
|
|
327
|
+
auth = (self._username, self._password)
|
|
328
|
+
|
|
329
|
+
try:
|
|
330
|
+
response = requests.get(url, auth=auth, timeout=10)
|
|
331
|
+
response.raise_for_status()
|
|
332
|
+
self.logger.debug(
|
|
333
|
+
"Calpendo MRI operator response OK (status %s) for booking %s",
|
|
334
|
+
response.status_code,
|
|
335
|
+
booking_id,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
data = response.json()
|
|
339
|
+
biskits = data.get("biskits", [])
|
|
340
|
+
if biskits and len(biskits) > 0:
|
|
341
|
+
operator_data = biskits[0].get("properties", {}).get("Operator", {})
|
|
342
|
+
return operator_data.get("name")
|
|
343
|
+
|
|
344
|
+
return None
|
|
345
|
+
|
|
346
|
+
except Exception as e:
|
|
347
|
+
self.logger.warning(f"Failed to fetch operator for booking {booking_id}: {e}")
|
|
348
|
+
return None
|
|
349
|
+
|
|
350
|
+
def _fetch_booking_details_parallel(self, booking_ids: List[int]) -> List[Dict]:
|
|
351
|
+
"""Fetch booking details in parallel using ThreadPoolExecutor."""
|
|
352
|
+
with ThreadPoolExecutor(max_workers=5) as executor:
|
|
353
|
+
results = list(executor.map(self._fetch_booking_details, booking_ids))
|
|
354
|
+
|
|
355
|
+
# Filter out None (deleted bookings)
|
|
356
|
+
bookings = [b for b in results if b is not None]
|
|
357
|
+
|
|
358
|
+
self.logger.debug(f"Fetched {len(bookings)}/{len(booking_ids)} booking details")
|
|
359
|
+
return bookings
|
|
360
|
+
|
|
361
|
+
def _extract_field_with_regex(self, source_value: str, extract_config: Dict) -> str:
|
|
362
|
+
"""
|
|
363
|
+
Apply regex pattern to extract field value.
|
|
364
|
+
|
|
365
|
+
Falls back to original value if pattern doesn't match.
|
|
366
|
+
"""
|
|
367
|
+
if not source_value:
|
|
368
|
+
return ""
|
|
369
|
+
|
|
370
|
+
pattern_str = extract_config.get("pattern")
|
|
371
|
+
group_num = extract_config.get("group", 0)
|
|
372
|
+
|
|
373
|
+
try:
|
|
374
|
+
pattern = re.compile(pattern_str)
|
|
375
|
+
match = pattern.match(source_value)
|
|
376
|
+
|
|
377
|
+
if match:
|
|
378
|
+
return match.group(group_num)
|
|
379
|
+
else:
|
|
380
|
+
self.logger.warning(
|
|
381
|
+
f"Regex pattern '{pattern_str}' no match for '{source_value}', "
|
|
382
|
+
f"using original value"
|
|
383
|
+
)
|
|
384
|
+
return source_value
|
|
385
|
+
|
|
386
|
+
except Exception as e:
|
|
387
|
+
raise PluginConfigError(f"Invalid regex pattern '{pattern_str}': {e}") from e
|
|
388
|
+
|
|
389
|
+
def _parse_formatted_name_dates(self, formatted_name: str) -> Tuple[datetime, datetime]:
|
|
390
|
+
"""
|
|
391
|
+
Extract start/end times from formattedName field.
|
|
392
|
+
|
|
393
|
+
Format: "[2026-01-27 14:00:00.0, 2026-01-27 15:30:00.0]"
|
|
394
|
+
"""
|
|
395
|
+
pattern = r"\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+), (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+)\]"
|
|
396
|
+
match = re.match(pattern, formatted_name)
|
|
397
|
+
|
|
398
|
+
if not match:
|
|
399
|
+
raise ValueError(f"Invalid formattedName format: {formatted_name}")
|
|
400
|
+
|
|
401
|
+
start_str, end_str = match.groups()
|
|
402
|
+
start_naive = datetime.strptime(start_str, "%Y-%m-%d %H:%M:%S.%f")
|
|
403
|
+
end_naive = datetime.strptime(end_str, "%Y-%m-%d %H:%M:%S.%f")
|
|
404
|
+
|
|
405
|
+
# Localize to configured timezone
|
|
406
|
+
start_dt = self._timezone.localize(start_naive)
|
|
407
|
+
end_dt = self._timezone.localize(end_naive)
|
|
408
|
+
|
|
409
|
+
return start_dt, end_dt
|
|
410
|
+
|
|
411
|
+
def _convert_to_local(self, dt: datetime) -> datetime:
|
|
412
|
+
"""Convert timezone-aware datetime to configured local timezone."""
|
|
413
|
+
return dt.astimezone(self._timezone)
|
|
414
|
+
|
|
415
|
+
def _map_status_to_dicom(self, calpendo_status: str) -> str:
|
|
416
|
+
"""Map Calpendo status to DICOM procedure step status."""
|
|
417
|
+
return self.STATUS_MAPPING.get(calpendo_status, "SCHEDULED")
|
|
418
|
+
|
|
419
|
+
def _map_resource_to_modality(self, resource_name: str) -> str:
|
|
420
|
+
"""
|
|
421
|
+
Map resource name to modality code.
|
|
422
|
+
|
|
423
|
+
Supports exact and prefix matching from config.
|
|
424
|
+
"""
|
|
425
|
+
mapping = self._config.get("resource_modality_mapping", {})
|
|
426
|
+
|
|
427
|
+
# Exact match
|
|
428
|
+
if resource_name in mapping:
|
|
429
|
+
return mapping[resource_name]
|
|
430
|
+
|
|
431
|
+
# Prefix match
|
|
432
|
+
for prefix, modality in mapping.items():
|
|
433
|
+
if resource_name.startswith(prefix):
|
|
434
|
+
return modality
|
|
435
|
+
|
|
436
|
+
# Default to resource name
|
|
437
|
+
return resource_name
|
|
438
|
+
|
|
439
|
+
def _get_nested_value(self, data: Dict, key_path: str) -> Optional[str]:
|
|
440
|
+
"""
|
|
441
|
+
Extract nested value from dict using dot notation.
|
|
442
|
+
|
|
443
|
+
Example: "properties.project.formattedName" → data["properties"]["project"]["formattedName"]
|
|
444
|
+
"""
|
|
445
|
+
keys = key_path.split(".")
|
|
446
|
+
value = data
|
|
447
|
+
|
|
448
|
+
for key in keys:
|
|
449
|
+
if isinstance(value, dict):
|
|
450
|
+
value = value.get(key)
|
|
451
|
+
if value is None:
|
|
452
|
+
return None
|
|
453
|
+
else:
|
|
454
|
+
return None
|
|
455
|
+
|
|
456
|
+
if value is None and len(keys) == 1 and isinstance(data, dict):
|
|
457
|
+
properties = data.get("properties")
|
|
458
|
+
if isinstance(properties, dict) and key_path in properties:
|
|
459
|
+
value = properties.get(key_path)
|
|
460
|
+
|
|
461
|
+
return str(value) if value is not None else None
|
|
462
|
+
|
|
463
|
+
def _parse_date_range_dates(self, date_range: Dict) -> Optional[Tuple[datetime, datetime]]:
|
|
464
|
+
"""Parse dateRange.start/end into timezone-aware datetimes."""
|
|
465
|
+
if not isinstance(date_range, dict):
|
|
466
|
+
return None
|
|
467
|
+
|
|
468
|
+
start_str = date_range.get("start")
|
|
469
|
+
end_str = date_range.get("end") or date_range.get("finish")
|
|
470
|
+
|
|
471
|
+
if not start_str or not end_str:
|
|
472
|
+
return None
|
|
473
|
+
|
|
474
|
+
def _parse_iso(value: str) -> Optional[datetime]:
|
|
475
|
+
try:
|
|
476
|
+
normalized = value.replace("Z", "+00:00")
|
|
477
|
+
dt = datetime.fromisoformat(normalized)
|
|
478
|
+
if dt.tzinfo is None:
|
|
479
|
+
dt = self._timezone.localize(dt)
|
|
480
|
+
return dt
|
|
481
|
+
except Exception:
|
|
482
|
+
return None
|
|
483
|
+
|
|
484
|
+
start_dt = _parse_iso(start_str)
|
|
485
|
+
end_dt = _parse_iso(end_str)
|
|
486
|
+
|
|
487
|
+
if start_dt and end_dt:
|
|
488
|
+
return (start_dt, end_dt)
|
|
489
|
+
return None
|
|
490
|
+
|
|
491
|
+
def _transform_booking_to_entry(
|
|
492
|
+
self,
|
|
493
|
+
booking: Dict,
|
|
494
|
+
field_mapping: Dict[str, str]
|
|
495
|
+
) -> Optional[Dict]:
|
|
496
|
+
"""
|
|
497
|
+
Transform Calpendo booking to worklist entry.
|
|
498
|
+
|
|
499
|
+
Applies field mappings, regex extraction, timezone conversion,
|
|
500
|
+
and status/resource mapping.
|
|
501
|
+
"""
|
|
502
|
+
entry = {}
|
|
503
|
+
|
|
504
|
+
# Apply field mappings
|
|
505
|
+
for target_field, mapping_config in field_mapping.items():
|
|
506
|
+
source_value = None
|
|
507
|
+
|
|
508
|
+
if isinstance(mapping_config, dict):
|
|
509
|
+
# Complex mapping with extraction
|
|
510
|
+
source_key = mapping_config.get("source_field")
|
|
511
|
+
if source_key:
|
|
512
|
+
source_value = self._get_nested_value(booking, source_key)
|
|
513
|
+
self.logger.debug(
|
|
514
|
+
"Calpendo mapping: target=%s source=%s raw_value=%s",
|
|
515
|
+
target_field,
|
|
516
|
+
source_key,
|
|
517
|
+
source_value,
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
# Apply regex extraction if configured
|
|
521
|
+
if source_value and "_extract" in mapping_config:
|
|
522
|
+
source_value = self._extract_field_with_regex(
|
|
523
|
+
source_value, mapping_config["_extract"]
|
|
524
|
+
)
|
|
525
|
+
self.logger.debug(
|
|
526
|
+
"Calpendo extraction: target=%s extracted_value=%s",
|
|
527
|
+
target_field,
|
|
528
|
+
source_value,
|
|
529
|
+
)
|
|
530
|
+
else:
|
|
531
|
+
# Simple string mapping
|
|
532
|
+
source_value = self._get_nested_value(booking, mapping_config)
|
|
533
|
+
self.logger.debug(
|
|
534
|
+
"Calpendo mapping: target=%s source=%s raw_value=%s",
|
|
535
|
+
target_field,
|
|
536
|
+
mapping_config,
|
|
537
|
+
source_value,
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
entry[target_field] = source_value
|
|
541
|
+
|
|
542
|
+
# Fallback to properties.title if patient_id/patient_name still missing
|
|
543
|
+
properties_title = None
|
|
544
|
+
if isinstance(booking.get("properties"), dict):
|
|
545
|
+
properties_title = booking.get("properties", {}).get("title")
|
|
546
|
+
|
|
547
|
+
booking_title = booking.get("title")
|
|
548
|
+
|
|
549
|
+
if properties_title is not None and not str(properties_title).strip():
|
|
550
|
+
properties_title = None
|
|
551
|
+
if booking_title is not None and not str(booking_title).strip():
|
|
552
|
+
booking_title = None
|
|
553
|
+
|
|
554
|
+
if not entry.get("patient_id"):
|
|
555
|
+
entry["patient_id"] = properties_title or booking_title
|
|
556
|
+
|
|
557
|
+
if not entry.get("patient_name"):
|
|
558
|
+
entry["patient_name"] = properties_title or booking_title
|
|
559
|
+
|
|
560
|
+
allowed_studies = self._config.get("allowed_studies") if self._config else None
|
|
561
|
+
if allowed_studies:
|
|
562
|
+
study_description = entry.get("study_description")
|
|
563
|
+
normalized_description = study_description.strip() if isinstance(study_description, str) else None
|
|
564
|
+
if not normalized_description or normalized_description not in allowed_studies:
|
|
565
|
+
self.logger.info(
|
|
566
|
+
"Skipping booking %s: study_description not allowed (value=%s allowed=%s)",
|
|
567
|
+
booking.get("id"),
|
|
568
|
+
study_description,
|
|
569
|
+
allowed_studies,
|
|
570
|
+
)
|
|
571
|
+
return None
|
|
572
|
+
|
|
573
|
+
# Extract and convert date/time from formattedName
|
|
574
|
+
formatted_name = booking.get("formattedName")
|
|
575
|
+
if formatted_name:
|
|
576
|
+
try:
|
|
577
|
+
start_dt, end_dt = self._parse_formatted_name_dates(formatted_name)
|
|
578
|
+
start_local = self._convert_to_local(start_dt)
|
|
579
|
+
end_local = self._convert_to_local(end_dt)
|
|
580
|
+
|
|
581
|
+
entry["scheduled_start_date"] = start_local.strftime("%Y-%m-%d")
|
|
582
|
+
entry["scheduled_start_time"] = start_local.strftime("%H:%M")
|
|
583
|
+
|
|
584
|
+
# Calculate duration in minutes
|
|
585
|
+
duration = (end_local - start_local).total_seconds() / 60
|
|
586
|
+
entry["scheduled_procedure_step_duration"] = int(duration)
|
|
587
|
+
|
|
588
|
+
except Exception as e:
|
|
589
|
+
self.logger.warning(
|
|
590
|
+
f"Failed to parse formattedName for booking {booking.get('id')}: {e}"
|
|
591
|
+
)
|
|
592
|
+
else:
|
|
593
|
+
date_range = self._get_nested_value(booking, "properties.dateRange")
|
|
594
|
+
parsed = self._parse_date_range_dates(booking.get("properties", {}).get("dateRange"))
|
|
595
|
+
if parsed:
|
|
596
|
+
start_dt, end_dt = parsed
|
|
597
|
+
self.logger.debug(
|
|
598
|
+
"Calpendo dateRange parsed: booking_id=%s start=%s end=%s",
|
|
599
|
+
booking.get("id"),
|
|
600
|
+
start_dt,
|
|
601
|
+
end_dt,
|
|
602
|
+
)
|
|
603
|
+
start_local = self._convert_to_local(start_dt)
|
|
604
|
+
end_local = self._convert_to_local(end_dt)
|
|
605
|
+
|
|
606
|
+
entry["scheduled_start_date"] = start_local.strftime("%Y-%m-%d")
|
|
607
|
+
entry["scheduled_start_time"] = start_local.strftime("%H:%M")
|
|
608
|
+
|
|
609
|
+
duration = (end_local - start_local).total_seconds() / 60
|
|
610
|
+
entry["scheduled_procedure_step_duration"] = int(duration)
|
|
611
|
+
else:
|
|
612
|
+
self.logger.warning(
|
|
613
|
+
"Booking %s missing formattedName and parsable dateRange; dateRange=%s",
|
|
614
|
+
booking.get("id"),
|
|
615
|
+
date_range,
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
# Map status
|
|
619
|
+
status_value = booking.get("status") or self._get_nested_value(booking, "properties.status")
|
|
620
|
+
if status_value:
|
|
621
|
+
entry["performed_procedure_step_status"] = self._map_status_to_dicom(
|
|
622
|
+
status_value
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
# Map resource to modality
|
|
626
|
+
resource_name = self._get_nested_value(booking, "properties.resource.formattedName")
|
|
627
|
+
if resource_name:
|
|
628
|
+
entry["modality"] = self._map_resource_to_modality(resource_name)
|
|
629
|
+
|
|
630
|
+
# Track data source
|
|
631
|
+
entry["data_source"] = self.get_source_name()
|
|
632
|
+
|
|
633
|
+
# Add booking hash for change detection
|
|
634
|
+
booking_hash = self._compute_booking_hash(booking)
|
|
635
|
+
entry["notes"] = json.dumps({"booking_hash": booking_hash})
|
|
636
|
+
|
|
637
|
+
# Validate required fields
|
|
638
|
+
required = ["patient_id", "scheduled_start_date", "scheduled_start_time"]
|
|
639
|
+
for field in required:
|
|
640
|
+
if not entry.get(field):
|
|
641
|
+
booking_id = booking.get("id")
|
|
642
|
+
if field == "patient_id":
|
|
643
|
+
title_value = booking.get("title")
|
|
644
|
+
properties_title_value = None
|
|
645
|
+
if isinstance(booking.get("properties"), dict):
|
|
646
|
+
properties_title_value = booking.get("properties", {}).get("title")
|
|
647
|
+
if not title_value:
|
|
648
|
+
title_value = properties_title_value
|
|
649
|
+
mapping_config = field_mapping.get("patient_id")
|
|
650
|
+
self.logger.warning(
|
|
651
|
+
"Booking %s missing required field '%s', skipping. "
|
|
652
|
+
"title=%s properties_title=%s mapping=%s extracted_patient_id=%s extracted_patient_name=%s "
|
|
653
|
+
"field_mapping_keys=%s booking_keys=%s properties_keys=%s biskitType=%s",
|
|
654
|
+
booking_id,
|
|
655
|
+
field,
|
|
656
|
+
title_value,
|
|
657
|
+
properties_title_value,
|
|
658
|
+
mapping_config,
|
|
659
|
+
entry.get("patient_id"),
|
|
660
|
+
entry.get("patient_name"),
|
|
661
|
+
list(field_mapping.keys()),
|
|
662
|
+
list(booking.keys()),
|
|
663
|
+
list(booking.get("properties", {}).keys()) if isinstance(booking.get("properties"), dict) else None,
|
|
664
|
+
booking.get("biskitType"),
|
|
665
|
+
)
|
|
666
|
+
else:
|
|
667
|
+
self.logger.warning(
|
|
668
|
+
"Booking %s missing required field '%s', skipping",
|
|
669
|
+
booking_id,
|
|
670
|
+
field,
|
|
671
|
+
)
|
|
672
|
+
return None
|
|
673
|
+
|
|
674
|
+
return entry
|
|
675
|
+
|
|
676
|
+
def _compute_booking_hash(self, booking: Dict) -> str:
|
|
677
|
+
"""
|
|
678
|
+
Compute SHA256 hash of critical booking fields for change detection.
|
|
679
|
+
"""
|
|
680
|
+
critical_fields = {
|
|
681
|
+
"title": booking.get("title", ""),
|
|
682
|
+
"status": booking.get("status", ""),
|
|
683
|
+
"formattedName": booking.get("formattedName", ""),
|
|
684
|
+
"project": self._get_nested_value(booking, "properties.project.formattedName") or "",
|
|
685
|
+
"resource": self._get_nested_value(booking, "properties.resource.formattedName") or "",
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
json_str = json.dumps(critical_fields, sort_keys=True)
|
|
689
|
+
hash_hex = hashlib.sha256(json_str.encode()).hexdigest()
|
|
690
|
+
return hash_hex
|
|
691
|
+
|
|
692
|
+
def get_source_name(self) -> str:
|
|
693
|
+
"""Return human-readable source type identifier."""
|
|
694
|
+
return "Calpendo"
|
|
695
|
+
|
|
696
|
+
def supports_incremental_sync(self) -> bool:
|
|
697
|
+
"""Calpendo plugin supports incremental sync via rolling window."""
|
|
698
|
+
return True
|
|
699
|
+
|
|
700
|
+
def cleanup(self) -> None:
|
|
701
|
+
"""Perform cleanup after sync."""
|
|
702
|
+
gc.collect()
|