pylantir 0.2.3__py3-none-any.whl → 0.3.0__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.
@@ -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()