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.
- 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.0.dist-info}/METADATA +305 -23
- pylantir-0.3.0.dist-info/RECORD +25 -0
- pylantir-0.2.3.dist-info/RECORD +0 -20
- {pylantir-0.2.3.dist-info → pylantir-0.3.0.dist-info}/WHEEL +0 -0
- {pylantir-0.2.3.dist-info → pylantir-0.3.0.dist-info}/entry_points.txt +0 -0
- {pylantir-0.2.3.dist-info → pylantir-0.3.0.dist-info}/licenses/LICENSE +0 -0
pylantir/api_server.py
CHANGED
|
@@ -21,7 +21,8 @@ from typing import List, Optional, Dict, Any
|
|
|
21
21
|
from datetime import datetime, timedelta
|
|
22
22
|
|
|
23
23
|
try:
|
|
24
|
-
from fastapi import FastAPI, HTTPException, Depends,
|
|
24
|
+
from fastapi import FastAPI, HTTPException, Depends, Query
|
|
25
|
+
from fastapi import status as http_status
|
|
25
26
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
26
27
|
from fastapi.middleware.cors import CORSMiddleware
|
|
27
28
|
from pydantic import BaseModel, validator
|
|
@@ -144,6 +145,7 @@ class WorklistItemResponse(BaseModel):
|
|
|
144
145
|
protocol_name: Optional[str]
|
|
145
146
|
station_name: Optional[str]
|
|
146
147
|
performed_procedure_step_status: Optional[str]
|
|
148
|
+
data_source: Optional[str] = None
|
|
147
149
|
|
|
148
150
|
class Config:
|
|
149
151
|
from_attributes = True
|
|
@@ -169,6 +171,7 @@ class WorklistItemCreate(BaseModel):
|
|
|
169
171
|
protocol_name: Optional[str] = None
|
|
170
172
|
station_name: Optional[str] = None
|
|
171
173
|
performed_procedure_step_status: str = "SCHEDULED"
|
|
174
|
+
data_source: Optional[str] = None
|
|
172
175
|
|
|
173
176
|
@validator('performed_procedure_step_status')
|
|
174
177
|
def validate_status(cls, v):
|
|
@@ -195,6 +198,7 @@ class WorklistItemUpdate(BaseModel):
|
|
|
195
198
|
protocol_name: Optional[str] = None
|
|
196
199
|
station_name: Optional[str] = None
|
|
197
200
|
performed_procedure_step_status: Optional[str] = None
|
|
201
|
+
data_source: Optional[str] = None
|
|
198
202
|
|
|
199
203
|
@validator('performed_procedure_step_status')
|
|
200
204
|
def validate_status(cls, v):
|
|
@@ -296,7 +300,7 @@ async def get_current_user(
|
|
|
296
300
|
|
|
297
301
|
if payload is None:
|
|
298
302
|
raise HTTPException(
|
|
299
|
-
status_code=
|
|
303
|
+
status_code=http_status.HTTP_401_UNAUTHORIZED,
|
|
300
304
|
detail="Invalid authentication token",
|
|
301
305
|
headers={"WWW-Authenticate": "Bearer"},
|
|
302
306
|
)
|
|
@@ -304,7 +308,7 @@ async def get_current_user(
|
|
|
304
308
|
username = payload.get("sub")
|
|
305
309
|
if username is None:
|
|
306
310
|
raise HTTPException(
|
|
307
|
-
status_code=
|
|
311
|
+
status_code=http_status.HTTP_401_UNAUTHORIZED,
|
|
308
312
|
detail="Invalid token payload",
|
|
309
313
|
headers={"WWW-Authenticate": "Bearer"},
|
|
310
314
|
)
|
|
@@ -312,7 +316,7 @@ async def get_current_user(
|
|
|
312
316
|
user = auth_db.query(User).filter(User.username == username).first()
|
|
313
317
|
if user is None or not user.is_active:
|
|
314
318
|
raise HTTPException(
|
|
315
|
-
status_code=
|
|
319
|
+
status_code=http_status.HTTP_401_UNAUTHORIZED,
|
|
316
320
|
detail="User not found or inactive",
|
|
317
321
|
headers={"WWW-Authenticate": "Bearer"},
|
|
318
322
|
)
|
|
@@ -324,7 +328,7 @@ async def get_current_user(
|
|
|
324
328
|
except Exception as e:
|
|
325
329
|
lgr.error(f"Authentication error: {e}")
|
|
326
330
|
raise HTTPException(
|
|
327
|
-
status_code=
|
|
331
|
+
status_code=http_status.HTTP_401_UNAUTHORIZED,
|
|
328
332
|
detail="Authentication failed",
|
|
329
333
|
headers={"WWW-Authenticate": "Bearer"},
|
|
330
334
|
)
|
|
@@ -344,7 +348,7 @@ def require_permission(action: str, resource: str = "worklist"):
|
|
|
344
348
|
def permission_checker(current_user: User = Depends(get_current_user)) -> User:
|
|
345
349
|
if not current_user.has_permission(action, resource):
|
|
346
350
|
raise HTTPException(
|
|
347
|
-
status_code=
|
|
351
|
+
status_code=http_status.HTTP_403_FORBIDDEN,
|
|
348
352
|
detail=f"Insufficient permissions for {action} on {resource}"
|
|
349
353
|
)
|
|
350
354
|
return current_user
|
|
@@ -364,7 +368,7 @@ async def login(
|
|
|
364
368
|
|
|
365
369
|
if not user:
|
|
366
370
|
raise HTTPException(
|
|
367
|
-
status_code=
|
|
371
|
+
status_code=http_status.HTTP_401_UNAUTHORIZED,
|
|
368
372
|
detail="Invalid username or password",
|
|
369
373
|
headers={"WWW-Authenticate": "Bearer"},
|
|
370
374
|
)
|
|
@@ -390,7 +394,7 @@ async def login(
|
|
|
390
394
|
except Exception as e:
|
|
391
395
|
lgr.error(f"Login error: {e}")
|
|
392
396
|
raise HTTPException(
|
|
393
|
-
status_code=
|
|
397
|
+
status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
394
398
|
detail="Login failed"
|
|
395
399
|
)
|
|
396
400
|
|
|
@@ -437,7 +441,7 @@ async def get_worklist_items(
|
|
|
437
441
|
except Exception as e:
|
|
438
442
|
lgr.error(f"Error retrieving worklist items: {e}")
|
|
439
443
|
raise HTTPException(
|
|
440
|
-
status_code=
|
|
444
|
+
status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
441
445
|
detail="Failed to retrieve worklist items"
|
|
442
446
|
)
|
|
443
447
|
|
pylantir/cli/run.py
CHANGED
|
@@ -160,6 +160,7 @@ def parse_args():
|
|
|
160
160
|
def load_config(config_path=None):
|
|
161
161
|
"""
|
|
162
162
|
Load configuration file, either from a user-provided path or the default package location.
|
|
163
|
+
Auto-converts legacy configuration format to new data_sources format.
|
|
163
164
|
|
|
164
165
|
Args:
|
|
165
166
|
config_path (str | Path, optional): Path to the configuration JSON file.
|
|
@@ -176,6 +177,35 @@ def load_config(config_path=None):
|
|
|
176
177
|
with config_path.open("r") as f:
|
|
177
178
|
config_data = json.load(f)
|
|
178
179
|
lgr.info(f"Loaded configuration from {config_path}")
|
|
180
|
+
|
|
181
|
+
# Auto-convert legacy configuration format
|
|
182
|
+
if "data_sources" not in config_data and "redcap2wl" in config_data:
|
|
183
|
+
lgr.warning(
|
|
184
|
+
"Legacy configuration format detected. "
|
|
185
|
+
"Consider migrating to 'data_sources' format for better flexibility. "
|
|
186
|
+
"See config/mwl_config_multi_source_example.json for reference."
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# Convert legacy format to data_sources array
|
|
190
|
+
legacy_source = {
|
|
191
|
+
"name": "redcap_legacy",
|
|
192
|
+
"type": "redcap",
|
|
193
|
+
"enabled": True,
|
|
194
|
+
"sync_interval": config_data.get("db_update_interval", 60),
|
|
195
|
+
"operation_interval": config_data.get(
|
|
196
|
+
"operation_interval",
|
|
197
|
+
{"start_time": [0, 0], "end_time": [23, 59]}
|
|
198
|
+
),
|
|
199
|
+
"config": {
|
|
200
|
+
"site_id": config_data.get("site"),
|
|
201
|
+
"protocol": config_data.get("protocol", {}),
|
|
202
|
+
},
|
|
203
|
+
"field_mapping": config_data.get("redcap2wl", {})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
config_data["data_sources"] = [legacy_source]
|
|
207
|
+
lgr.info("Auto-converted legacy configuration to data_sources format")
|
|
208
|
+
|
|
179
209
|
return config_data
|
|
180
210
|
|
|
181
211
|
except FileNotFoundError:
|
|
@@ -285,52 +315,286 @@ def main() -> None:
|
|
|
285
315
|
# Load configuration into environment variables
|
|
286
316
|
update_env_with_config(config)
|
|
287
317
|
|
|
288
|
-
|
|
289
318
|
from ..mwl_server import run_mwl_server
|
|
290
|
-
from ..redcap_to_db import sync_redcap_to_db_repeatedly
|
|
291
|
-
|
|
292
|
-
# Extract the database update interval (default to 60 seconds if missing)
|
|
293
|
-
db_update_interval = config.get("db_update_interval", 60)
|
|
294
|
-
|
|
295
|
-
# Extract the operation interval (default from 00:00 to 23:59 hours if missing)
|
|
296
|
-
operation_interval = config.get("operation_interval", {"start_time": [0,0], "end_time": [23,59]})
|
|
297
319
|
|
|
298
320
|
# Extract allowed AE Titles (default to empty list if missing)
|
|
299
321
|
allowed_aet = config.get("allowed_aet", [])
|
|
300
322
|
|
|
301
|
-
#
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
redcap2wl = config.get("redcap2wl", {})
|
|
323
|
+
# Check if using new data_sources format or legacy format
|
|
324
|
+
if "data_sources" in config:
|
|
325
|
+
# NEW: Multi-source orchestration using plugin architecture
|
|
326
|
+
lgr.info("Using new data_sources configuration format")
|
|
306
327
|
|
|
307
|
-
|
|
308
|
-
|
|
328
|
+
from ..data_sources import get_plugin
|
|
329
|
+
from ..data_sources.base import PluginError
|
|
330
|
+
from ..redcap_to_db import STOP_EVENT
|
|
331
|
+
import threading
|
|
309
332
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
future = executor.submit(
|
|
313
|
-
sync_redcap_to_db_repeatedly,
|
|
314
|
-
site_id=site,
|
|
315
|
-
protocol=protocol,
|
|
316
|
-
redcap2wl=redcap2wl,
|
|
317
|
-
interval=db_update_interval,
|
|
318
|
-
operation_interval=operation_interval,
|
|
319
|
-
)
|
|
333
|
+
data_sources = config.get("data_sources", [])
|
|
334
|
+
enabled_sources = [src for src in data_sources if src.get("enabled", True)]
|
|
320
335
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
336
|
+
if not enabled_sources:
|
|
337
|
+
lgr.warning("No enabled data sources found in configuration")
|
|
338
|
+
else:
|
|
339
|
+
lgr.info(f"Found {len(enabled_sources)} enabled data source(s)")
|
|
340
|
+
|
|
341
|
+
def sync_data_source_repeatedly(source_config):
|
|
342
|
+
"""
|
|
343
|
+
Generic sync loop for any data source plugin.
|
|
344
|
+
|
|
345
|
+
This function works with any plugin type (REDCap, CSV, API, etc.)
|
|
346
|
+
by using the plugin interface rather than source-specific code.
|
|
347
|
+
"""
|
|
348
|
+
source_name = source_config.get("name", "unknown")
|
|
349
|
+
source_type = source_config.get("type", "unknown")
|
|
350
|
+
|
|
351
|
+
try:
|
|
352
|
+
# Get and instantiate the plugin
|
|
353
|
+
PluginClass = get_plugin(source_type)
|
|
354
|
+
lgr.info(f"[{source_name}] Initializing {source_type} plugin")
|
|
355
|
+
|
|
356
|
+
plugin = PluginClass()
|
|
357
|
+
|
|
358
|
+
# Validate plugin configuration
|
|
359
|
+
plugin_config = dict(source_config.get("config", {}))
|
|
360
|
+
if "field_mapping" in source_config:
|
|
361
|
+
plugin_config["field_mapping"] = source_config.get("field_mapping")
|
|
362
|
+
if "window_mode" in source_config:
|
|
363
|
+
plugin_config["window_mode"] = source_config.get("window_mode")
|
|
364
|
+
if "daily_window" in source_config:
|
|
365
|
+
plugin_config["daily_window"] = source_config.get("daily_window")
|
|
366
|
+
|
|
367
|
+
is_valid, error_msg = plugin.validate_config(plugin_config)
|
|
368
|
+
if not is_valid:
|
|
369
|
+
lgr.error(f"[{source_name}] Configuration validation failed: {error_msg}")
|
|
370
|
+
return
|
|
371
|
+
|
|
372
|
+
# Extract sync settings
|
|
373
|
+
sync_interval = source_config.get("sync_interval", 60)
|
|
374
|
+
operation_interval = source_config.get("operation_interval", {
|
|
375
|
+
"start_time": [0, 0],
|
|
376
|
+
"end_time": [23, 59]
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
lgr.info(f"[{source_name}] Starting sync loop (interval: {sync_interval}s)")
|
|
380
|
+
|
|
381
|
+
# Import database and sync utilities
|
|
382
|
+
from ..db_setup import Session
|
|
383
|
+
from ..models import WorklistItem
|
|
384
|
+
from ..redcap_to_db import generate_instance_uid, cleanup_memory_and_connections
|
|
385
|
+
from datetime import datetime, time as dt_time, timedelta
|
|
386
|
+
import logging
|
|
387
|
+
|
|
388
|
+
# Parse operation interval
|
|
389
|
+
start_h, start_m = operation_interval.get("start_time", [0, 0])
|
|
390
|
+
end_h, end_m = operation_interval.get("end_time", [23, 59])
|
|
391
|
+
start_time = dt_time(start_h, start_m)
|
|
392
|
+
end_time = dt_time(end_h, end_m)
|
|
393
|
+
|
|
394
|
+
last_sync_date = datetime.now().date() - timedelta(days=1)
|
|
395
|
+
interval_sync = sync_interval + 300 # Overlap to avoid missing data
|
|
396
|
+
|
|
397
|
+
# Sync loop
|
|
398
|
+
while not STOP_EVENT.is_set():
|
|
399
|
+
is_first_run = False
|
|
400
|
+
extended_interval = sync_interval
|
|
401
|
+
|
|
402
|
+
now_dt = datetime.now().replace(second=0, microsecond=0)
|
|
403
|
+
now_time = now_dt.time()
|
|
404
|
+
today_date = now_dt.date()
|
|
405
|
+
|
|
406
|
+
# Only sync within operation interval
|
|
407
|
+
if start_time <= now_time <= end_time:
|
|
408
|
+
is_first_run = (last_sync_date != today_date)
|
|
409
|
+
|
|
410
|
+
if is_first_run and (last_sync_date is not None):
|
|
411
|
+
yesterday = last_sync_date
|
|
412
|
+
dt_end_yesterday = datetime.combine(yesterday, end_time)
|
|
413
|
+
dt_start_today = datetime.combine(today_date, start_time)
|
|
414
|
+
delta = dt_start_today - dt_end_yesterday
|
|
415
|
+
extended_interval = delta.total_seconds()
|
|
416
|
+
# temporary increase interval to cover gap since last sync
|
|
417
|
+
# extended_interval += 6000000
|
|
418
|
+
logging.info(f"[{source_name}] First sync of the day at {now_time}")
|
|
419
|
+
|
|
420
|
+
# Fetch entries using plugin
|
|
421
|
+
try:
|
|
422
|
+
fetch_interval = extended_interval if is_first_run else interval_sync
|
|
423
|
+
field_mapping = source_config.get("field_mapping", {})
|
|
424
|
+
|
|
425
|
+
lgr.debug(f"[{source_name}] Fetching entries (interval: {fetch_interval}s)")
|
|
426
|
+
entries = plugin.fetch_entries(
|
|
427
|
+
field_mapping=field_mapping,
|
|
428
|
+
interval=fetch_interval
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
if entries:
|
|
432
|
+
lgr.info(f"[{source_name}] Fetched {len(entries)} entries")
|
|
433
|
+
|
|
434
|
+
# Get source-specific config
|
|
435
|
+
site_id = source_config.get("config", {}).get("site_id")
|
|
436
|
+
protocol = config.get("protocol", {})
|
|
437
|
+
|
|
438
|
+
# Process entries (source-agnostic)
|
|
439
|
+
session = Session()
|
|
440
|
+
try:
|
|
441
|
+
def _format_date(value):
|
|
442
|
+
if value is None:
|
|
443
|
+
return None
|
|
444
|
+
if hasattr(value, "strftime"):
|
|
445
|
+
return value.strftime("%Y-%m-%d")
|
|
446
|
+
value_str = str(value).strip()
|
|
447
|
+
if "-" in value_str and len(value_str) >= 10:
|
|
448
|
+
return value_str[:10]
|
|
449
|
+
if len(value_str) == 8 and value_str.isdigit():
|
|
450
|
+
return f"{value_str[0:4]}-{value_str[4:6]}-{value_str[6:8]}"
|
|
451
|
+
return value_str
|
|
452
|
+
|
|
453
|
+
def _format_time(value):
|
|
454
|
+
if value is None:
|
|
455
|
+
return None
|
|
456
|
+
if hasattr(value, "strftime"):
|
|
457
|
+
return value.strftime("%H:%M")
|
|
458
|
+
value_str = str(value).strip()
|
|
459
|
+
if ":" in value_str:
|
|
460
|
+
parts = value_str.split(":")
|
|
461
|
+
if len(parts) >= 2:
|
|
462
|
+
hh = parts[0].zfill(2)
|
|
463
|
+
mm = parts[1].zfill(2)
|
|
464
|
+
return f"{hh}:{mm}"
|
|
465
|
+
if len(value_str) == 6 and value_str.isdigit():
|
|
466
|
+
return f"{value_str[0:2]}:{value_str[2:4]}"
|
|
467
|
+
if len(value_str) == 4 and value_str.isdigit():
|
|
468
|
+
return f"{value_str[0:2]}:{value_str[2:4]}"
|
|
469
|
+
return value_str
|
|
470
|
+
|
|
471
|
+
for record in entries:
|
|
472
|
+
patient_id = record.get("patient_id")
|
|
473
|
+
lgr.info(f"[{source_name}] Processing record for patient_id: {patient_id}")
|
|
474
|
+
if not patient_id:
|
|
475
|
+
lgr.info(f"[{source_name}] Skipping record with missing patient_id")
|
|
476
|
+
continue
|
|
477
|
+
|
|
478
|
+
existing_entry = session.query(WorklistItem).filter_by(patient_id=patient_id).first()
|
|
479
|
+
|
|
480
|
+
scheduled_start_date = _format_date(record.get("scheduled_start_date"))
|
|
481
|
+
scheduled_start_time = _format_time(record.get("scheduled_start_time"))
|
|
482
|
+
|
|
483
|
+
if existing_entry:
|
|
484
|
+
existing_entry.data_source = record.get("data_source") or source_name
|
|
485
|
+
existing_entry.scheduled_start_date = scheduled_start_date
|
|
486
|
+
existing_entry.scheduled_start_time = scheduled_start_time
|
|
487
|
+
else:
|
|
488
|
+
new_entry = WorklistItem(
|
|
489
|
+
study_instance_uid=record.get("study_instance_uid") or generate_instance_uid(),
|
|
490
|
+
patient_name=record.get("patient_name"),
|
|
491
|
+
patient_id=patient_id,
|
|
492
|
+
patient_birth_date=record.get("patient_birth_date"),
|
|
493
|
+
patient_sex=record.get("patient_sex"),
|
|
494
|
+
patient_weight_lb=record.get("patient_weight_lb"),
|
|
495
|
+
accession_number=record.get("accession_number"),
|
|
496
|
+
referring_physician_name=record.get("referring_physician_name"),
|
|
497
|
+
modality=record.get("modality", "MR"),
|
|
498
|
+
study_description=record.get("study_description"),
|
|
499
|
+
scheduled_station_aetitle=record.get("scheduled_station_aetitle"),
|
|
500
|
+
scheduled_start_date=scheduled_start_date,
|
|
501
|
+
scheduled_start_time=scheduled_start_time,
|
|
502
|
+
performing_physician=record.get("performing_physician"),
|
|
503
|
+
procedure_description=record.get("procedure_description"),
|
|
504
|
+
protocol_name=record.get("protocol_name") or protocol.get(site_id, "DEFAULT_PROTOCOL"),
|
|
505
|
+
station_name=record.get("station_name"),
|
|
506
|
+
hisris_coding_designator=record.get("hisris_coding_designator"),
|
|
507
|
+
performed_procedure_step_status=record.get(
|
|
508
|
+
"performed_procedure_step_status"
|
|
509
|
+
) or "SCHEDULED",
|
|
510
|
+
data_source=record.get("data_source") or source_name
|
|
511
|
+
)
|
|
512
|
+
session.add(new_entry)
|
|
513
|
+
|
|
514
|
+
session.commit()
|
|
515
|
+
lgr.info(f"[{source_name}] Sync completed successfully")
|
|
516
|
+
except Exception as e:
|
|
517
|
+
session.rollback()
|
|
518
|
+
lgr.error(f"[{source_name}] Database error: {e}")
|
|
519
|
+
finally:
|
|
520
|
+
session.expunge_all()
|
|
521
|
+
session.close()
|
|
522
|
+
cleanup_memory_and_connections()
|
|
523
|
+
|
|
524
|
+
last_sync_date = today_date
|
|
525
|
+
|
|
526
|
+
except Exception as e:
|
|
527
|
+
lgr.error(f"[{source_name}] Sync error: {e}")
|
|
528
|
+
import traceback
|
|
529
|
+
traceback.print_exc()
|
|
530
|
+
|
|
531
|
+
# Wait before next iteration
|
|
532
|
+
STOP_EVENT.wait(sync_interval)
|
|
533
|
+
|
|
534
|
+
lgr.info(f"[{source_name}] Exiting sync loop (STOP_EVENT set)")
|
|
535
|
+
|
|
536
|
+
except PluginError as e:
|
|
537
|
+
lgr.error(f"[{source_name}] Plugin error: {e}")
|
|
538
|
+
except Exception as e:
|
|
539
|
+
lgr.error(f"[{source_name}] Unexpected error: {e}")
|
|
540
|
+
import traceback
|
|
541
|
+
traceback.print_exc()
|
|
542
|
+
|
|
543
|
+
# Start a thread for each enabled data source
|
|
544
|
+
max_workers = len(enabled_sources) + 1 # +1 for MWL server
|
|
545
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
546
|
+
# Submit sync tasks for each data source
|
|
547
|
+
for source in enabled_sources:
|
|
548
|
+
source_name = source.get("name", "unknown")
|
|
549
|
+
lgr.info(f"Starting background sync for data source: {source_name}")
|
|
550
|
+
executor.submit(sync_data_source_repeatedly, source)
|
|
551
|
+
|
|
552
|
+
# Start the MWL server in the main thread
|
|
553
|
+
run_mwl_server(
|
|
554
|
+
host=args.ip,
|
|
555
|
+
port=args.port,
|
|
556
|
+
aetitle=args.AEtitle,
|
|
557
|
+
allowed_aets=allowed_aet,
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
else:
|
|
561
|
+
# LEGACY: Fall back to old single-source configuration
|
|
562
|
+
lgr.warning("Using legacy configuration format. Consider migrating to data_sources format.")
|
|
563
|
+
|
|
564
|
+
from ..redcap_to_db import sync_redcap_to_db_repeatedly
|
|
565
|
+
|
|
566
|
+
# Extract the database update interval (default to 60 seconds if missing)
|
|
567
|
+
db_update_interval = config.get("db_update_interval", 60)
|
|
568
|
+
|
|
569
|
+
# Extract the operation interval (default from 00:00 to 23:59 hours if missing)
|
|
570
|
+
operation_interval = config.get("operation_interval", {"start_time": [0,0], "end_time": [23,59]})
|
|
571
|
+
|
|
572
|
+
# Extract the site id
|
|
573
|
+
site = config.get("site", None)
|
|
574
|
+
|
|
575
|
+
# Extract the redcap to worklist mapping
|
|
576
|
+
redcap2wl = config.get("redcap2wl", {})
|
|
577
|
+
|
|
578
|
+
# Extract protocol mapping
|
|
579
|
+
protocol = config.get("protocol", {})
|
|
580
|
+
|
|
581
|
+
# Create and update the MWL database
|
|
582
|
+
with ThreadPoolExecutor(max_workers=2) as executor:
|
|
583
|
+
future = executor.submit(
|
|
584
|
+
sync_redcap_to_db_repeatedly,
|
|
585
|
+
site_id=site,
|
|
586
|
+
protocol=protocol,
|
|
587
|
+
redcap2wl=redcap2wl,
|
|
588
|
+
interval=db_update_interval,
|
|
589
|
+
operation_interval=operation_interval,
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
run_mwl_server(
|
|
593
|
+
host=args.ip,
|
|
594
|
+
port=args.port,
|
|
595
|
+
aetitle=args.AEtitle,
|
|
596
|
+
allowed_aets=allowed_aet,
|
|
597
|
+
)
|
|
334
598
|
|
|
335
599
|
if (args.command == "query-db"):
|
|
336
600
|
from ..mwl_server import run_mwl_server
|
|
@@ -374,14 +638,16 @@ def main() -> None:
|
|
|
374
638
|
try:
|
|
375
639
|
# Check if API dependencies are available
|
|
376
640
|
import uvicorn
|
|
377
|
-
from ..api_server import app
|
|
378
|
-
from ..auth_db_setup import init_auth_database, create_initial_admin_user
|
|
379
641
|
|
|
380
642
|
# Load configuration for database setup
|
|
381
643
|
config = load_config(args.pylantir_config)
|
|
382
644
|
update_env_with_config(config)
|
|
383
645
|
users_db_path = config.get("users_db_path") # Optional users database path
|
|
384
646
|
|
|
647
|
+
# Import API app after env vars are set (DB_PATH, DB_ECHO, etc.)
|
|
648
|
+
from ..api_server import app
|
|
649
|
+
from ..auth_db_setup import init_auth_database, create_initial_admin_user
|
|
650
|
+
|
|
385
651
|
# Initialize authentication database with configured path
|
|
386
652
|
init_auth_database(users_db_path)
|
|
387
653
|
create_initial_admin_user(users_db_path)
|
|
@@ -482,7 +748,7 @@ def main() -> None:
|
|
|
482
748
|
email = args.email or input("Enter email (optional): ") or None
|
|
483
749
|
full_name = args.full_name or input("Enter full name (optional): ") or None
|
|
484
750
|
password = args.password or getpass.getpass("Enter password for new user: ")
|
|
485
|
-
|
|
751
|
+
|
|
486
752
|
# Get user role with interactive prompt
|
|
487
753
|
if args.role == "read": # Default value, prompt for role
|
|
488
754
|
print("\nAvailable user roles:")
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"db_path": "~/Desktop/worklist.db",
|
|
3
|
+
"db_echo": "False",
|
|
4
|
+
"db_update_interval": 60,
|
|
5
|
+
"operation_interval": {
|
|
6
|
+
"start_time": [
|
|
7
|
+
0,
|
|
8
|
+
0
|
|
9
|
+
],
|
|
10
|
+
"end_time": [
|
|
11
|
+
23,
|
|
12
|
+
59
|
|
13
|
+
]
|
|
14
|
+
},
|
|
15
|
+
"allowed_aet": [
|
|
16
|
+
"MRI_SCANNER",
|
|
17
|
+
"CT_SCANNER"
|
|
18
|
+
],
|
|
19
|
+
"data_sources": [
|
|
20
|
+
{
|
|
21
|
+
"type": "calpendo",
|
|
22
|
+
"enabled": true,
|
|
23
|
+
"config": {
|
|
24
|
+
"base_url": "https://sfc-calgary.calpendo.com",
|
|
25
|
+
"resources": [
|
|
26
|
+
"3T Diagnostic",
|
|
27
|
+
"EEG Lab"
|
|
28
|
+
],
|
|
29
|
+
"status_filter": "Approved",
|
|
30
|
+
"lookback_multiplier": 2,
|
|
31
|
+
"timezone": "America/Edmonton",
|
|
32
|
+
"resource_modality_mapping": {
|
|
33
|
+
"3T": "MR",
|
|
34
|
+
"EEG": "EEG",
|
|
35
|
+
"Mock": "OT"
|
|
36
|
+
},
|
|
37
|
+
"field_mapping": {
|
|
38
|
+
"patient_id": {
|
|
39
|
+
"source_field": "title",
|
|
40
|
+
"_extract": {
|
|
41
|
+
"pattern": "^([A-Z0-9]+)_.*",
|
|
42
|
+
"group": 1
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"patient_name": {
|
|
46
|
+
"source_field": "title",
|
|
47
|
+
"_extract": {
|
|
48
|
+
"pattern": "^[A-Z0-9]+_(.+)$",
|
|
49
|
+
"group": 1
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"study_description": {
|
|
53
|
+
"source_field": "properties.project.formattedName",
|
|
54
|
+
"_extract": {
|
|
55
|
+
"pattern": "^([^(]+)",
|
|
56
|
+
"group": 1
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"accession_number": "id",
|
|
60
|
+
"study_instance_uid": "id"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
}
|
pylantir/config/mwl_config.json
CHANGED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data Source Plugin Registry
|
|
3
|
+
|
|
4
|
+
This module provides the plugin registry system for discovering and loading
|
|
5
|
+
data source plugins.
|
|
6
|
+
|
|
7
|
+
Version: 1.0.0
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .base import DataSourcePlugin, PluginError, PluginConfigError, PluginFetchError
|
|
11
|
+
from .redcap_plugin import REDCapPlugin
|
|
12
|
+
from .calpendo_plugin import CalendoPlugin
|
|
13
|
+
from typing import Type, Dict, List
|
|
14
|
+
import logging
|
|
15
|
+
|
|
16
|
+
lgr = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# Plugin Registry - maps source type names to plugin classes
|
|
19
|
+
PLUGIN_REGISTRY: Dict[str, Type[DataSourcePlugin]] = {
|
|
20
|
+
"redcap": REDCapPlugin,
|
|
21
|
+
"calpendo": CalendoPlugin,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def register_plugin(source_type: str, plugin_class: Type[DataSourcePlugin]) -> None:
|
|
26
|
+
"""
|
|
27
|
+
Register a plugin class in the registry.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
source_type: Type identifier for the plugin (e.g., "redcap", "csv")
|
|
31
|
+
plugin_class: Plugin class inheriting from DataSourcePlugin
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
ValueError: If source_type already registered or plugin_class is invalid
|
|
35
|
+
"""
|
|
36
|
+
if source_type in PLUGIN_REGISTRY:
|
|
37
|
+
raise ValueError(f"Plugin type '{source_type}' is already registered")
|
|
38
|
+
|
|
39
|
+
if not issubclass(plugin_class, DataSourcePlugin):
|
|
40
|
+
raise ValueError(f"Plugin class must inherit from DataSourcePlugin")
|
|
41
|
+
|
|
42
|
+
PLUGIN_REGISTRY[source_type] = plugin_class
|
|
43
|
+
lgr.info(f"Registered plugin: {source_type} -> {plugin_class.__name__}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_plugin(source_type: str) -> Type[DataSourcePlugin]:
|
|
47
|
+
"""
|
|
48
|
+
Retrieve a plugin class from the registry.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
source_type: Type identifier for the plugin
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Plugin class for instantiation
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
ValueError: If source_type is not registered
|
|
58
|
+
"""
|
|
59
|
+
if source_type not in PLUGIN_REGISTRY:
|
|
60
|
+
available_types = list(PLUGIN_REGISTRY.keys())
|
|
61
|
+
raise ValueError(
|
|
62
|
+
f"Unknown data source type: '{source_type}'. "
|
|
63
|
+
f"Available types: {available_types}"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return PLUGIN_REGISTRY[source_type]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def list_available_plugins() -> List[str]:
|
|
70
|
+
"""Return list of registered plugin type names."""
|
|
71
|
+
return list(PLUGIN_REGISTRY.keys())
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# Export public API
|
|
75
|
+
__all__ = [
|
|
76
|
+
'DataSourcePlugin',
|
|
77
|
+
'PluginError',
|
|
78
|
+
'PluginConfigError',
|
|
79
|
+
'PluginFetchError',
|
|
80
|
+
'PLUGIN_REGISTRY',
|
|
81
|
+
'register_plugin',
|
|
82
|
+
'get_plugin',
|
|
83
|
+
'list_available_plugins',
|
|
84
|
+
]
|