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/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(db_path="~/Desktop/worklist.db", db_echo="False", env_path=".env"):
215
+ def update_env_with_config(config):
160
216
  """
161
- Updates db_path from the config to DB_PATH in .env.
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 the default env_path to the src/pylantir folder
172
- dot_env_path = pkg_resources.files("pylantir").joinpath(env_path)
173
- dot_env_path = Path.Path(dot_env_path)
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
- # Write to .env using python-dotenv's set_key
176
- set_key(dot_env_path, "DB_PATH", db_path_expanded)
177
- set_key(dot_env_path, "DB_ECHO", db_echo)
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
- lgr.debug(f"DB_PATH set to {db_path_expanded} and DB_ECHO to {db_echo} in {dot_env_path}")
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
- # Extract the database path (default to worklist.db if missing) &
200
- # Extract the database echo setting (default to False if missing)
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
+ }
@@ -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)