pylantir 0.1.3__py3-none-any.whl → 0.2.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/__init__.py +1 -1
- pylantir/api_server.py +769 -0
- pylantir/auth_db_setup.py +188 -0
- pylantir/auth_models.py +80 -0
- pylantir/auth_utils.py +210 -0
- pylantir/cli/run.py +322 -16
- pylantir/config/config_example_with_cors.json +108 -0
- pylantir/config/mwl_config.json +18 -0
- pylantir/db_concurrency.py +180 -0
- pylantir/db_setup.py +78 -3
- pylantir/redcap_to_db.py +225 -91
- pylantir-0.2.1.dist-info/METADATA +584 -0
- pylantir-0.2.1.dist-info/RECORD +20 -0
- pylantir-0.1.3.dist-info/METADATA +0 -193
- pylantir-0.1.3.dist-info/RECORD +0 -14
- {pylantir-0.1.3.dist-info → pylantir-0.2.1.dist-info}/WHEEL +0 -0
- {pylantir-0.1.3.dist-info → pylantir-0.2.1.dist-info}/entry_points.txt +0 -0
- {pylantir-0.1.3.dist-info → pylantir-0.2.1.dist-info}/licenses/LICENSE +0 -0
pylantir/cli/run.py
CHANGED
|
@@ -39,11 +39,16 @@ def parse_args():
|
|
|
39
39
|
help="""
|
|
40
40
|
Command to run:
|
|
41
41
|
- start: start the MWL server
|
|
42
|
-
- query: query the MWL db
|
|
42
|
+
- query-db: query the MWL db
|
|
43
43
|
- test-client: run tests for MWL
|
|
44
44
|
- test-mpps: run tests for MPPS
|
|
45
|
+
- start-api: start the FastAPI server (requires [api] dependencies)
|
|
46
|
+
- admin-password: change admin password
|
|
47
|
+
- create-user: create a new user (admin only)
|
|
48
|
+
- list-users: list all users (admin only)
|
|
45
49
|
""",
|
|
46
|
-
choices=["start", "query-db", "test-client", "test-mpps"
|
|
50
|
+
choices=["start", "query-db", "test-client", "test-mpps",
|
|
51
|
+
"start-api", "admin-password", "create-user", "list-users"],
|
|
47
52
|
)
|
|
48
53
|
p.add_argument("--AEtitle", help="AE Title for the server")
|
|
49
54
|
p.add_argument("--ip", help="IP/host address for the server", default="0.0.0.0")
|
|
@@ -99,6 +104,57 @@ def parse_args():
|
|
|
99
104
|
help="SOPInstanceUID to test MPPS",
|
|
100
105
|
)
|
|
101
106
|
|
|
107
|
+
# API server arguments
|
|
108
|
+
p.add_argument(
|
|
109
|
+
"--api-host",
|
|
110
|
+
default="0.0.0.0",
|
|
111
|
+
type=str,
|
|
112
|
+
help="API server host address (default: 0.0.0.0)"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
p.add_argument(
|
|
116
|
+
"--api-port",
|
|
117
|
+
default=8000,
|
|
118
|
+
type=int,
|
|
119
|
+
help="API server port (default: 8000)"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# User management arguments
|
|
123
|
+
p.add_argument(
|
|
124
|
+
"--username",
|
|
125
|
+
default=None,
|
|
126
|
+
type=str,
|
|
127
|
+
help="Username for user operations"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
p.add_argument(
|
|
131
|
+
"--password",
|
|
132
|
+
default=None,
|
|
133
|
+
type=str,
|
|
134
|
+
help="Password for user operations"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
p.add_argument(
|
|
138
|
+
"--email",
|
|
139
|
+
default=None,
|
|
140
|
+
type=str,
|
|
141
|
+
help="Email for user creation"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
p.add_argument(
|
|
145
|
+
"--full-name",
|
|
146
|
+
default=None,
|
|
147
|
+
type=str,
|
|
148
|
+
help="Full name for user creation"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
p.add_argument(
|
|
152
|
+
"--role",
|
|
153
|
+
default="read",
|
|
154
|
+
choices=["admin", "write", "read"],
|
|
155
|
+
help="User role (default: read)"
|
|
156
|
+
)
|
|
157
|
+
|
|
102
158
|
return p.parse_args()
|
|
103
159
|
|
|
104
160
|
def load_config(config_path=None):
|
|
@@ -156,10 +212,17 @@ def run_test_script(script_name, **kwargs):
|
|
|
156
212
|
else:
|
|
157
213
|
lgr.error(f"Test script {script_name} does not have a 'main' function.")
|
|
158
214
|
|
|
159
|
-
def update_env_with_config(
|
|
215
|
+
def update_env_with_config(config):
|
|
160
216
|
"""
|
|
161
|
-
Updates
|
|
217
|
+
Updates environment variables from configuration.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
config: Configuration dictionary
|
|
162
221
|
"""
|
|
222
|
+
# Extract values from config with defaults
|
|
223
|
+
db_path = config.get("db_path", "~/Desktop/worklist.db")
|
|
224
|
+
db_echo = str(config.get("db_echo", "False"))
|
|
225
|
+
users_db_path = config.get("users_db_path")
|
|
163
226
|
|
|
164
227
|
# Expand the db_path from the config
|
|
165
228
|
try:
|
|
@@ -168,15 +231,38 @@ def update_env_with_config(db_path="~/Desktop/worklist.db", db_echo="False", env
|
|
|
168
231
|
lgr.error("Invalid db_path in config.")
|
|
169
232
|
return
|
|
170
233
|
|
|
171
|
-
# Set
|
|
172
|
-
|
|
173
|
-
|
|
234
|
+
# Set environment variables directly (for API server)
|
|
235
|
+
os.environ["DB_PATH"] = db_path_expanded
|
|
236
|
+
os.environ["DB_ECHO"] = db_echo
|
|
237
|
+
|
|
238
|
+
# Set users database path if provided in config
|
|
239
|
+
if users_db_path:
|
|
240
|
+
try:
|
|
241
|
+
users_db_path_expanded = os.path.expanduser(users_db_path)
|
|
242
|
+
os.environ["USERS_DB_PATH"] = users_db_path_expanded
|
|
243
|
+
lgr.debug(f"USERS_DB_PATH set to {users_db_path_expanded}")
|
|
244
|
+
except AttributeError:
|
|
245
|
+
lgr.error("Invalid users_db_path in config.")
|
|
246
|
+
|
|
247
|
+
# Set CORS configuration if provided
|
|
248
|
+
api_config = config.get("api", {})
|
|
249
|
+
if "cors_allowed_origins" in api_config:
|
|
250
|
+
import json
|
|
251
|
+
os.environ["CORS_ALLOWED_ORIGINS"] = json.dumps(api_config["cors_allowed_origins"])
|
|
252
|
+
lgr.debug(f"CORS origins set to {api_config['cors_allowed_origins']}")
|
|
253
|
+
|
|
254
|
+
if "cors_allow_credentials" in api_config:
|
|
255
|
+
os.environ["CORS_ALLOW_CREDENTIALS"] = str(api_config["cors_allow_credentials"])
|
|
174
256
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
257
|
+
if "cors_allow_methods" in api_config:
|
|
258
|
+
import json
|
|
259
|
+
os.environ["CORS_ALLOW_METHODS"] = json.dumps(api_config["cors_allow_methods"])
|
|
178
260
|
|
|
179
|
-
|
|
261
|
+
if "cors_allow_headers" in api_config:
|
|
262
|
+
import json
|
|
263
|
+
os.environ["CORS_ALLOW_HEADERS"] = json.dumps(api_config["cors_allow_headers"])
|
|
264
|
+
|
|
265
|
+
lgr.debug(f"Environment configured: DB_PATH={db_path_expanded}, DB_ECHO={db_echo}")
|
|
180
266
|
|
|
181
267
|
def main() -> None:
|
|
182
268
|
args = parse_args()
|
|
@@ -196,11 +282,8 @@ def main() -> None:
|
|
|
196
282
|
if (args.command == "start"):
|
|
197
283
|
# Load configuration (either user-specified or default)
|
|
198
284
|
config = load_config(args.pylantir_config)
|
|
199
|
-
#
|
|
200
|
-
|
|
201
|
-
db_path = config.get("db_path", "./worklist.db")
|
|
202
|
-
db_echo = config.get("db_echo", "False")
|
|
203
|
-
update_env_with_config(db_path=db_path, db_echo=db_echo)
|
|
285
|
+
# Load configuration into environment variables
|
|
286
|
+
update_env_with_config(config)
|
|
204
287
|
|
|
205
288
|
|
|
206
289
|
from ..mwl_server import run_mwl_server
|
|
@@ -286,6 +369,229 @@ def main() -> None:
|
|
|
286
369
|
sop_instance_uid=args.sop_uid,
|
|
287
370
|
)
|
|
288
371
|
|
|
372
|
+
if (args.command == "start-api"):
|
|
373
|
+
lgr.info("Starting Pylantir FastAPI server")
|
|
374
|
+
try:
|
|
375
|
+
# Check if API dependencies are available
|
|
376
|
+
import uvicorn
|
|
377
|
+
from ..api_server import app
|
|
378
|
+
from ..auth_db_setup import init_auth_database, create_initial_admin_user
|
|
379
|
+
|
|
380
|
+
# Load configuration for database setup
|
|
381
|
+
config = load_config(args.pylantir_config)
|
|
382
|
+
update_env_with_config(config)
|
|
383
|
+
users_db_path = config.get("users_db_path") # Optional users database path
|
|
384
|
+
|
|
385
|
+
# Initialize authentication database with configured path
|
|
386
|
+
init_auth_database(users_db_path)
|
|
387
|
+
create_initial_admin_user(users_db_path)
|
|
388
|
+
|
|
389
|
+
lgr.info(f"API server starting on {args.api_host}:{args.api_port}")
|
|
390
|
+
lgr.info("API documentation available at /docs")
|
|
391
|
+
lgr.info("Default admin credentials: username='admin', password='admin123'")
|
|
392
|
+
lgr.warning("Change the admin password immediately using 'pylantir admin-password'")
|
|
393
|
+
|
|
394
|
+
uvicorn.run(app, host=args.api_host, port=args.api_port)
|
|
395
|
+
|
|
396
|
+
except ImportError:
|
|
397
|
+
lgr.error("API dependencies not installed. Install with: pip install pylantir[api]")
|
|
398
|
+
sys.exit(1)
|
|
399
|
+
except Exception as e:
|
|
400
|
+
lgr.error(f"Failed to start API server: {e}")
|
|
401
|
+
sys.exit(1)
|
|
402
|
+
|
|
403
|
+
if (args.command == "admin-password"):
|
|
404
|
+
lgr.info("Changing admin password")
|
|
405
|
+
try:
|
|
406
|
+
from ..auth_db_setup import get_auth_db, init_auth_database
|
|
407
|
+
from ..auth_models import User, UserRole
|
|
408
|
+
from ..auth_utils import get_password_hash
|
|
409
|
+
import getpass
|
|
410
|
+
|
|
411
|
+
# Load configuration to get users_db_path if available
|
|
412
|
+
config = load_config(args.pylantir_config) if hasattr(args, 'pylantir_config') and args.pylantir_config else {}
|
|
413
|
+
users_db_path = config.get("users_db_path")
|
|
414
|
+
|
|
415
|
+
# Initialize database
|
|
416
|
+
init_auth_database(users_db_path)
|
|
417
|
+
|
|
418
|
+
# Get current password
|
|
419
|
+
current_password = getpass.getpass("Enter current admin password: ")
|
|
420
|
+
|
|
421
|
+
# Get new password
|
|
422
|
+
new_password = getpass.getpass("Enter new password: ")
|
|
423
|
+
confirm_password = getpass.getpass("Confirm new password: ")
|
|
424
|
+
|
|
425
|
+
if new_password != confirm_password:
|
|
426
|
+
lgr.error("Passwords do not match")
|
|
427
|
+
sys.exit(1)
|
|
428
|
+
|
|
429
|
+
if len(new_password) < 8:
|
|
430
|
+
lgr.error("Password must be at least 8 characters long")
|
|
431
|
+
sys.exit(1)
|
|
432
|
+
|
|
433
|
+
# Update password in database
|
|
434
|
+
db = next(get_auth_db())
|
|
435
|
+
admin_user = db.query(User).filter(
|
|
436
|
+
User.username == (args.username or "admin")
|
|
437
|
+
).first()
|
|
438
|
+
|
|
439
|
+
if not admin_user:
|
|
440
|
+
lgr.error("Admin user not found")
|
|
441
|
+
sys.exit(1)
|
|
442
|
+
|
|
443
|
+
from ..auth_utils import verify_password
|
|
444
|
+
if not verify_password(current_password, admin_user.hashed_password):
|
|
445
|
+
lgr.error("Current password is incorrect")
|
|
446
|
+
sys.exit(1)
|
|
447
|
+
|
|
448
|
+
# Update password
|
|
449
|
+
admin_user.hashed_password = get_password_hash(new_password)
|
|
450
|
+
db.commit()
|
|
451
|
+
|
|
452
|
+
lgr.info("Admin password updated successfully")
|
|
453
|
+
|
|
454
|
+
except ImportError:
|
|
455
|
+
lgr.error("API dependencies not installed. Install with: pip install pylantir[api]")
|
|
456
|
+
sys.exit(1)
|
|
457
|
+
except Exception as e:
|
|
458
|
+
lgr.error(f"Failed to change admin password: {e}")
|
|
459
|
+
sys.exit(1)
|
|
460
|
+
|
|
461
|
+
if (args.command == "create-user"):
|
|
462
|
+
lgr.info("Creating new user")
|
|
463
|
+
try:
|
|
464
|
+
from ..auth_db_setup import get_auth_db, init_auth_database
|
|
465
|
+
from ..auth_models import User, UserRole
|
|
466
|
+
from ..auth_utils import get_password_hash
|
|
467
|
+
import getpass
|
|
468
|
+
|
|
469
|
+
# Load configuration to get users_db_path if available
|
|
470
|
+
config = load_config(args.pylantir_config) if hasattr(args, 'pylantir_config') and args.pylantir_config else {}
|
|
471
|
+
users_db_path = config.get("users_db_path")
|
|
472
|
+
|
|
473
|
+
# Initialize database
|
|
474
|
+
init_auth_database(users_db_path)
|
|
475
|
+
|
|
476
|
+
# Get admin credentials
|
|
477
|
+
admin_username = input("Enter admin username: ") or "admin"
|
|
478
|
+
admin_password = getpass.getpass("Enter admin password: ")
|
|
479
|
+
|
|
480
|
+
# Get new user details
|
|
481
|
+
username = args.username or input("Enter new username: ")
|
|
482
|
+
email = args.email or input("Enter email (optional): ") or None
|
|
483
|
+
full_name = args.full_name or input("Enter full name (optional): ") or None
|
|
484
|
+
password = args.password or getpass.getpass("Enter password for new user: ")
|
|
485
|
+
|
|
486
|
+
# Get user role with interactive prompt
|
|
487
|
+
if args.role == "read": # Default value, prompt for role
|
|
488
|
+
print("\nAvailable user roles:")
|
|
489
|
+
print(" admin - Full administrative access")
|
|
490
|
+
print(" write - Can create, read, update, and delete records")
|
|
491
|
+
print(" read - Read-only access (default)")
|
|
492
|
+
role_input = input("Enter user role (admin/write/read) [read]: ").lower().strip()
|
|
493
|
+
if role_input in ["admin", "write", "read"]:
|
|
494
|
+
role = role_input
|
|
495
|
+
elif role_input == "":
|
|
496
|
+
role = "read" # Keep default
|
|
497
|
+
else:
|
|
498
|
+
lgr.error(f"Invalid role '{role_input}'. Valid roles are: admin, write, read")
|
|
499
|
+
sys.exit(1)
|
|
500
|
+
else:
|
|
501
|
+
role = args.role
|
|
502
|
+
|
|
503
|
+
if not username or not password:
|
|
504
|
+
lgr.error("Username and password are required")
|
|
505
|
+
sys.exit(1)
|
|
506
|
+
|
|
507
|
+
# Verify admin credentials
|
|
508
|
+
db = next(get_auth_db())
|
|
509
|
+
from ..auth_utils import authenticate_user
|
|
510
|
+
admin_user = authenticate_user(db, admin_username, admin_password)
|
|
511
|
+
|
|
512
|
+
if not admin_user or admin_user.role != UserRole.ADMIN:
|
|
513
|
+
lgr.error("Invalid admin credentials or insufficient permissions")
|
|
514
|
+
sys.exit(1)
|
|
515
|
+
|
|
516
|
+
# Check if username already exists
|
|
517
|
+
existing_user = db.query(User).filter(User.username == username).first()
|
|
518
|
+
if existing_user:
|
|
519
|
+
lgr.error(f"Username '{username}' already exists")
|
|
520
|
+
sys.exit(1)
|
|
521
|
+
|
|
522
|
+
# Create new user
|
|
523
|
+
from datetime import datetime
|
|
524
|
+
new_user = User(
|
|
525
|
+
username=username,
|
|
526
|
+
email=email,
|
|
527
|
+
full_name=full_name,
|
|
528
|
+
hashed_password=get_password_hash(password),
|
|
529
|
+
role=UserRole(role),
|
|
530
|
+
is_active=True,
|
|
531
|
+
created_at=datetime.utcnow(),
|
|
532
|
+
created_by=admin_user.id
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
db.add(new_user)
|
|
536
|
+
db.commit()
|
|
537
|
+
|
|
538
|
+
lgr.info(f"User '{username}' created successfully with role '{role}'")
|
|
539
|
+
|
|
540
|
+
except ImportError:
|
|
541
|
+
lgr.error("API dependencies not installed. Install with: pip install pylantir[api]")
|
|
542
|
+
sys.exit(1)
|
|
543
|
+
except Exception as e:
|
|
544
|
+
lgr.error(f"Failed to create user: {e}")
|
|
545
|
+
sys.exit(1)
|
|
546
|
+
|
|
547
|
+
if (args.command == "list-users"):
|
|
548
|
+
lgr.info("Listing all users")
|
|
549
|
+
try:
|
|
550
|
+
from ..auth_db_setup import get_auth_db, init_auth_database
|
|
551
|
+
from ..auth_models import User, UserRole
|
|
552
|
+
import getpass
|
|
553
|
+
|
|
554
|
+
# Load configuration to get users_db_path if available
|
|
555
|
+
config = load_config(args.pylantir_config) if hasattr(args, 'pylantir_config') and args.pylantir_config else {}
|
|
556
|
+
users_db_path = config.get("users_db_path")
|
|
557
|
+
|
|
558
|
+
# Initialize database
|
|
559
|
+
init_auth_database(users_db_path)
|
|
560
|
+
|
|
561
|
+
# Get admin credentials
|
|
562
|
+
admin_username = input("Enter admin username: ") or "admin"
|
|
563
|
+
admin_password = getpass.getpass("Enter admin password: ")
|
|
564
|
+
|
|
565
|
+
# Verify admin credentials
|
|
566
|
+
db = next(get_auth_db())
|
|
567
|
+
from ..auth_utils import authenticate_user
|
|
568
|
+
admin_user = authenticate_user(db, admin_username, admin_password)
|
|
569
|
+
|
|
570
|
+
if not admin_user or admin_user.role != UserRole.ADMIN:
|
|
571
|
+
lgr.error("Invalid admin credentials or insufficient permissions")
|
|
572
|
+
sys.exit(1)
|
|
573
|
+
|
|
574
|
+
# List all users
|
|
575
|
+
users = db.query(User).all()
|
|
576
|
+
|
|
577
|
+
print("\nUsers:")
|
|
578
|
+
print("=" * 80)
|
|
579
|
+
print(f"{'ID':<5} {'Username':<20} {'Role':<10} {'Active':<8} {'Email':<25} {'Last Login'}")
|
|
580
|
+
print("-" * 80)
|
|
581
|
+
|
|
582
|
+
for user in users:
|
|
583
|
+
last_login = user.last_login.strftime("%Y-%m-%d %H:%M") if user.last_login else "Never"
|
|
584
|
+
print(f"{user.id:<5} {user.username:<20} {user.role.value:<10} {user.is_active:<8} {user.email or 'N/A':<25} {last_login}")
|
|
585
|
+
|
|
586
|
+
print(f"\nTotal users: {len(users)}")
|
|
587
|
+
|
|
588
|
+
except ImportError:
|
|
589
|
+
lgr.error("API dependencies not installed. Install with: pip install pylantir[api]")
|
|
590
|
+
sys.exit(1)
|
|
591
|
+
except Exception as e:
|
|
592
|
+
lgr.error(f"Failed to list users: {e}")
|
|
593
|
+
sys.exit(1)
|
|
594
|
+
|
|
289
595
|
|
|
290
596
|
if __name__ == "__main__":
|
|
291
597
|
main()
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
{
|
|
2
|
+
"db_path": "/path/to/worklist.db",
|
|
3
|
+
"db_echo": "0",
|
|
4
|
+
"db_update_interval": 60,
|
|
5
|
+
"allowed_aet": [],
|
|
6
|
+
"operation_interval": {
|
|
7
|
+
"start_time": [
|
|
8
|
+
0,
|
|
9
|
+
0
|
|
10
|
+
],
|
|
11
|
+
"end_time": [
|
|
12
|
+
23,
|
|
13
|
+
59
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
"mri_visit_session_mapping": {
|
|
17
|
+
"t1_arm_1": "1",
|
|
18
|
+
"t2_arm_1": "2",
|
|
19
|
+
"t3_arm_1": "3"
|
|
20
|
+
},
|
|
21
|
+
"site": "792",
|
|
22
|
+
"redcap2wl": {
|
|
23
|
+
"study_id": "study_id",
|
|
24
|
+
"family_id": "family_id",
|
|
25
|
+
"youth_dob_y": "youth_dob_y",
|
|
26
|
+
"t1_date": "t1_date",
|
|
27
|
+
"demo_sex": "demo_sex",
|
|
28
|
+
"scheduled_date": "scheduled_start_date",
|
|
29
|
+
"scheduled_time": "scheduled_start_time",
|
|
30
|
+
"mri_wt_lbs": "patient_weight_lb",
|
|
31
|
+
"referring_physician": "referring_physician_name",
|
|
32
|
+
"performing_physician": "performing_physician",
|
|
33
|
+
"station_name": "station_name",
|
|
34
|
+
"status": "performed_procedure_step_status"
|
|
35
|
+
},
|
|
36
|
+
"protocol": {
|
|
37
|
+
"792": "BRAIN_MRI_3T"
|
|
38
|
+
},
|
|
39
|
+
"api": {
|
|
40
|
+
"_comment": "CORS Configuration Examples",
|
|
41
|
+
"_development": {
|
|
42
|
+
"cors_allowed_origins": [
|
|
43
|
+
"http://localhost:3000",
|
|
44
|
+
"http://localhost:8080"
|
|
45
|
+
],
|
|
46
|
+
"cors_allow_credentials": true,
|
|
47
|
+
"cors_allow_methods": [
|
|
48
|
+
"GET",
|
|
49
|
+
"POST",
|
|
50
|
+
"PUT",
|
|
51
|
+
"DELETE",
|
|
52
|
+
"OPTIONS"
|
|
53
|
+
],
|
|
54
|
+
"cors_allow_headers": [
|
|
55
|
+
"*"
|
|
56
|
+
]
|
|
57
|
+
},
|
|
58
|
+
"_production_single_domain": {
|
|
59
|
+
"cors_allowed_origins": [
|
|
60
|
+
"https://my-app.company.com"
|
|
61
|
+
],
|
|
62
|
+
"cors_allow_credentials": true,
|
|
63
|
+
"cors_allow_methods": [
|
|
64
|
+
"GET",
|
|
65
|
+
"POST",
|
|
66
|
+
"PUT",
|
|
67
|
+
"DELETE"
|
|
68
|
+
],
|
|
69
|
+
"cors_allow_headers": [
|
|
70
|
+
"Content-Type",
|
|
71
|
+
"Authorization"
|
|
72
|
+
]
|
|
73
|
+
},
|
|
74
|
+
"_production_multiple_domains": {
|
|
75
|
+
"cors_allowed_origins": [
|
|
76
|
+
"https://app.company.com",
|
|
77
|
+
"https://admin.company.com",
|
|
78
|
+
"https://mobile.company.com"
|
|
79
|
+
],
|
|
80
|
+
"cors_allow_credentials": true,
|
|
81
|
+
"cors_allow_methods": [
|
|
82
|
+
"GET",
|
|
83
|
+
"POST",
|
|
84
|
+
"PUT",
|
|
85
|
+
"DELETE"
|
|
86
|
+
],
|
|
87
|
+
"cors_allow_headers": [
|
|
88
|
+
"Content-Type",
|
|
89
|
+
"Authorization",
|
|
90
|
+
"X-API-Key"
|
|
91
|
+
]
|
|
92
|
+
},
|
|
93
|
+
"cors_allowed_origins": [
|
|
94
|
+
"http://localhost:3000"
|
|
95
|
+
],
|
|
96
|
+
"cors_allow_credentials": true,
|
|
97
|
+
"cors_allow_methods": [
|
|
98
|
+
"GET",
|
|
99
|
+
"POST",
|
|
100
|
+
"PUT",
|
|
101
|
+
"DELETE",
|
|
102
|
+
"OPTIONS"
|
|
103
|
+
],
|
|
104
|
+
"cors_allow_headers": [
|
|
105
|
+
"*"
|
|
106
|
+
]
|
|
107
|
+
}
|
|
108
|
+
}
|
pylantir/config/mwl_config.json
CHANGED
|
@@ -35,5 +35,23 @@
|
|
|
35
35
|
},
|
|
36
36
|
"protocol": {
|
|
37
37
|
"792": "BRAIN_MRI_3T"
|
|
38
|
+
},
|
|
39
|
+
"api": {
|
|
40
|
+
"cors_allowed_origins": [
|
|
41
|
+
"http://localhost:3000",
|
|
42
|
+
"http://localhost:8080",
|
|
43
|
+
"https://your-frontend-domain.com"
|
|
44
|
+
],
|
|
45
|
+
"cors_allow_credentials": true,
|
|
46
|
+
"cors_allow_methods": [
|
|
47
|
+
"GET",
|
|
48
|
+
"POST",
|
|
49
|
+
"PUT",
|
|
50
|
+
"DELETE",
|
|
51
|
+
"OPTIONS"
|
|
52
|
+
],
|
|
53
|
+
"cors_allow_headers": [
|
|
54
|
+
"*"
|
|
55
|
+
]
|
|
38
56
|
}
|
|
39
57
|
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Author: Milton Camacho
|
|
5
|
+
Date: 2025-11-18
|
|
6
|
+
Database transaction management utilities for safe concurrent access.
|
|
7
|
+
|
|
8
|
+
Ensures API operations don't interfere with RedCap sync functionality
|
|
9
|
+
through proper transaction isolation and retry logic.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import time
|
|
14
|
+
import functools
|
|
15
|
+
from contextlib import contextmanager
|
|
16
|
+
from sqlalchemy.exc import OperationalError, IntegrityError
|
|
17
|
+
from typing import Generator, Any, Callable
|
|
18
|
+
|
|
19
|
+
lgr = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
class DatabaseBusyError(Exception):
|
|
22
|
+
"""Raised when database is busy and operation should be retried."""
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def retry_on_database_busy(max_retries: int = 3, delay: float = 0.1):
|
|
27
|
+
"""
|
|
28
|
+
Decorator to retry database operations on SQLite busy/locked errors.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
max_retries: Maximum number of retry attempts
|
|
32
|
+
delay: Initial delay between retries (exponential backoff)
|
|
33
|
+
"""
|
|
34
|
+
def decorator(func: Callable) -> Callable:
|
|
35
|
+
@functools.wraps(func)
|
|
36
|
+
def wrapper(*args, **kwargs):
|
|
37
|
+
last_exception = None
|
|
38
|
+
|
|
39
|
+
for attempt in range(max_retries + 1):
|
|
40
|
+
try:
|
|
41
|
+
return func(*args, **kwargs)
|
|
42
|
+
except OperationalError as e:
|
|
43
|
+
last_exception = e
|
|
44
|
+
error_message = str(e).lower()
|
|
45
|
+
|
|
46
|
+
# Check if it's a database busy/locked error
|
|
47
|
+
if any(keyword in error_message for keyword in
|
|
48
|
+
['database is locked', 'database busy', 'locked']):
|
|
49
|
+
|
|
50
|
+
if attempt < max_retries:
|
|
51
|
+
wait_time = delay * (2 ** attempt) # Exponential backoff
|
|
52
|
+
lgr.warning(f"Database busy, retrying in {wait_time}s (attempt {attempt + 1}/{max_retries})")
|
|
53
|
+
time.sleep(wait_time)
|
|
54
|
+
continue
|
|
55
|
+
else:
|
|
56
|
+
lgr.error(f"Database busy after {max_retries} retries: {e}")
|
|
57
|
+
raise DatabaseBusyError(f"Database busy after {max_retries} retries") from e
|
|
58
|
+
else:
|
|
59
|
+
# Not a busy error, re-raise immediately
|
|
60
|
+
raise
|
|
61
|
+
except Exception as e:
|
|
62
|
+
# Non-database errors, re-raise immediately
|
|
63
|
+
raise
|
|
64
|
+
|
|
65
|
+
# This should never be reached, but just in case
|
|
66
|
+
if last_exception:
|
|
67
|
+
raise last_exception
|
|
68
|
+
else:
|
|
69
|
+
raise DatabaseBusyError("Unknown database error occurred")
|
|
70
|
+
|
|
71
|
+
return wrapper
|
|
72
|
+
return decorator
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@contextmanager
|
|
76
|
+
def safe_database_transaction(session) -> Generator[Any, None, None]:
|
|
77
|
+
"""
|
|
78
|
+
Context manager for safe database transactions with automatic rollback.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
session: SQLAlchemy database session
|
|
82
|
+
|
|
83
|
+
Yields:
|
|
84
|
+
session: The database session within transaction context
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
DatabaseBusyError: If database is busy after retries
|
|
88
|
+
IntegrityError: If data integrity constraints are violated
|
|
89
|
+
"""
|
|
90
|
+
try:
|
|
91
|
+
# Begin explicit transaction
|
|
92
|
+
session.begin()
|
|
93
|
+
|
|
94
|
+
yield session
|
|
95
|
+
|
|
96
|
+
# Commit if no exceptions occurred
|
|
97
|
+
session.commit()
|
|
98
|
+
lgr.debug("Database transaction committed successfully")
|
|
99
|
+
|
|
100
|
+
except OperationalError as e:
|
|
101
|
+
session.rollback()
|
|
102
|
+
error_message = str(e).lower()
|
|
103
|
+
|
|
104
|
+
if any(keyword in error_message for keyword in
|
|
105
|
+
['database is locked', 'database busy', 'locked']):
|
|
106
|
+
lgr.warning(f"Database transaction rolled back due to busy database: {e}")
|
|
107
|
+
raise DatabaseBusyError("Database is busy, transaction rolled back") from e
|
|
108
|
+
else:
|
|
109
|
+
lgr.error(f"Database transaction rolled back due to operational error: {e}")
|
|
110
|
+
raise
|
|
111
|
+
|
|
112
|
+
except IntegrityError as e:
|
|
113
|
+
session.rollback()
|
|
114
|
+
lgr.warning(f"Database transaction rolled back due to integrity error: {e}")
|
|
115
|
+
raise
|
|
116
|
+
|
|
117
|
+
except Exception as e:
|
|
118
|
+
session.rollback()
|
|
119
|
+
lgr.error(f"Database transaction rolled back due to unexpected error: {e}")
|
|
120
|
+
raise
|
|
121
|
+
|
|
122
|
+
finally:
|
|
123
|
+
# Ensure session is clean
|
|
124
|
+
session.close()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def isolation_level_read_committed(session):
|
|
128
|
+
"""
|
|
129
|
+
Set SQLite to use READ COMMITTED isolation level for better concurrency.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
session: SQLAlchemy database session
|
|
133
|
+
"""
|
|
134
|
+
try:
|
|
135
|
+
# SQLite pragma for better concurrency
|
|
136
|
+
session.execute("PRAGMA journal_mode=WAL") # Write-Ahead Logging for concurrent reads
|
|
137
|
+
session.execute("PRAGMA synchronous=NORMAL") # Balance safety and performance
|
|
138
|
+
session.execute("PRAGMA busy_timeout=30000") # 30 second timeout
|
|
139
|
+
session.execute("PRAGMA temp_store=MEMORY") # Use memory for temp tables
|
|
140
|
+
session.commit()
|
|
141
|
+
lgr.debug("Database isolation level configured for concurrency")
|
|
142
|
+
except Exception as e:
|
|
143
|
+
lgr.warning(f"Could not configure database isolation level: {e}")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class ConcurrencyManager:
|
|
147
|
+
"""
|
|
148
|
+
Manager class for handling database concurrency between API and RedCap sync.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
@staticmethod
|
|
152
|
+
def configure_api_session(session):
|
|
153
|
+
"""
|
|
154
|
+
Configure database session for API operations with concurrency optimizations.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
session: SQLAlchemy database session
|
|
158
|
+
"""
|
|
159
|
+
isolation_level_read_committed(session)
|
|
160
|
+
|
|
161
|
+
@staticmethod
|
|
162
|
+
@retry_on_database_busy(max_retries=5, delay=0.1)
|
|
163
|
+
def safe_api_operation(session, operation_func, *args, **kwargs):
|
|
164
|
+
"""
|
|
165
|
+
Execute API database operation with retry logic and transaction safety.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
session: Database session
|
|
169
|
+
operation_func: Function to execute database operation
|
|
170
|
+
*args, **kwargs: Arguments for operation function
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
Result of operation function
|
|
174
|
+
|
|
175
|
+
Raises:
|
|
176
|
+
DatabaseBusyError: If database remains busy after retries
|
|
177
|
+
"""
|
|
178
|
+
with safe_database_transaction(session) as tx_session:
|
|
179
|
+
ConcurrencyManager.configure_api_session(tx_session)
|
|
180
|
+
return operation_func(tx_session, *args, **kwargs)
|