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/api_server.py
ADDED
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Author: Milton Camacho
|
|
5
|
+
Date: 2025-11-18
|
|
6
|
+
FastAPI application for Pylantir worklist management.
|
|
7
|
+
|
|
8
|
+
Provides RESTful API endpoints for:
|
|
9
|
+
- GET /worklist: Retrieve worklist items with optional filtering
|
|
10
|
+
- POST /worklist: Create new worklist items
|
|
11
|
+
- PUT /worklist/{id}: Update existing worklist items
|
|
12
|
+
- DELETE /worklist/{id}: Delete worklist items
|
|
13
|
+
- GET /users: List users (admin only)
|
|
14
|
+
- POST /users: Create users (admin only)
|
|
15
|
+
- PUT /users/{id}: Update users (admin only)
|
|
16
|
+
- DELETE /users/{id}: Delete users (admin only)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
from typing import List, Optional, Dict, Any
|
|
21
|
+
from datetime import datetime, timedelta
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
from fastapi import FastAPI, HTTPException, Depends, status, Query
|
|
25
|
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
26
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
27
|
+
from pydantic import BaseModel, validator
|
|
28
|
+
except ImportError:
|
|
29
|
+
raise ImportError(
|
|
30
|
+
"FastAPI dependencies not installed. Install with: pip install pylantir[api]"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
from sqlalchemy.orm import Session
|
|
34
|
+
from sqlalchemy import and_, or_
|
|
35
|
+
import os
|
|
36
|
+
import json
|
|
37
|
+
|
|
38
|
+
from .db_setup import get_api_db
|
|
39
|
+
from .db_concurrency import ConcurrencyManager, safe_database_transaction, DatabaseBusyError
|
|
40
|
+
from .auth_db_setup import get_auth_db, init_auth_database, create_initial_admin_user
|
|
41
|
+
from .models import WorklistItem
|
|
42
|
+
from .auth_models import User, UserRole
|
|
43
|
+
from .auth_utils import (
|
|
44
|
+
authenticate_user,
|
|
45
|
+
create_access_token,
|
|
46
|
+
verify_token,
|
|
47
|
+
get_password_hash,
|
|
48
|
+
AuthenticationError,
|
|
49
|
+
AuthorizationError
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
lgr = logging.getLogger(__name__)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_cors_config():
|
|
56
|
+
"""
|
|
57
|
+
Get CORS configuration from environment variables set by CLI.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
dict: CORS configuration with defaults for security
|
|
61
|
+
"""
|
|
62
|
+
# Default secure CORS configuration
|
|
63
|
+
cors_config = {
|
|
64
|
+
"allow_origins": ["http://localhost:3000", "http://localhost:8080"],
|
|
65
|
+
"allow_credentials": True,
|
|
66
|
+
"allow_methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
67
|
+
"allow_headers": ["*"],
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# Load from environment variables if set by CLI
|
|
71
|
+
cors_origins = os.getenv("CORS_ALLOWED_ORIGINS")
|
|
72
|
+
if cors_origins:
|
|
73
|
+
try:
|
|
74
|
+
# Parse JSON array from environment variable
|
|
75
|
+
cors_config["allow_origins"] = json.loads(cors_origins)
|
|
76
|
+
except json.JSONDecodeError:
|
|
77
|
+
lgr.warning(f"Invalid CORS origins format: {cors_origins}, using defaults")
|
|
78
|
+
|
|
79
|
+
cors_credentials = os.getenv("CORS_ALLOW_CREDENTIALS")
|
|
80
|
+
if cors_credentials:
|
|
81
|
+
cors_config["allow_credentials"] = cors_credentials.lower() in ("true", "1")
|
|
82
|
+
|
|
83
|
+
cors_methods = os.getenv("CORS_ALLOW_METHODS")
|
|
84
|
+
if cors_methods:
|
|
85
|
+
try:
|
|
86
|
+
cors_config["allow_methods"] = json.loads(cors_methods)
|
|
87
|
+
except json.JSONDecodeError:
|
|
88
|
+
lgr.warning(f"Invalid CORS methods format: {cors_methods}, using defaults")
|
|
89
|
+
|
|
90
|
+
cors_headers = os.getenv("CORS_ALLOW_HEADERS")
|
|
91
|
+
if cors_headers:
|
|
92
|
+
try:
|
|
93
|
+
cors_config["allow_headers"] = json.loads(cors_headers)
|
|
94
|
+
except json.JSONDecodeError:
|
|
95
|
+
lgr.warning(f"Invalid CORS headers format: {cors_headers}, using defaults")
|
|
96
|
+
|
|
97
|
+
return cors_config
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# Initialize FastAPI app
|
|
101
|
+
app = FastAPI(
|
|
102
|
+
title="Pylantir API",
|
|
103
|
+
description="RESTful API for DICOM Modality Worklist Management",
|
|
104
|
+
version="0.2.0",
|
|
105
|
+
docs_url="/docs",
|
|
106
|
+
redoc_url="/redoc"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# CORS middleware with configurable origins
|
|
110
|
+
cors_config = get_cors_config()
|
|
111
|
+
app.add_middleware(
|
|
112
|
+
CORSMiddleware,
|
|
113
|
+
allow_origins=cors_config["allow_origins"],
|
|
114
|
+
allow_credentials=cors_config["allow_credentials"],
|
|
115
|
+
allow_methods=cors_config["allow_methods"],
|
|
116
|
+
allow_headers=cors_config["allow_headers"],
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
lgr.info(f"CORS configured with origins: {cors_config['allow_origins']}")
|
|
120
|
+
|
|
121
|
+
# Security
|
|
122
|
+
security = HTTPBearer()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# Pydantic models for API
|
|
126
|
+
class WorklistItemResponse(BaseModel):
|
|
127
|
+
"""Response model for worklist items."""
|
|
128
|
+
id: int
|
|
129
|
+
study_instance_uid: Optional[str]
|
|
130
|
+
patient_name: Optional[str]
|
|
131
|
+
patient_id: Optional[str]
|
|
132
|
+
patient_birth_date: Optional[str]
|
|
133
|
+
patient_sex: Optional[str]
|
|
134
|
+
patient_weight_lb: Optional[str]
|
|
135
|
+
accession_number: Optional[str]
|
|
136
|
+
referring_physician_name: Optional[str]
|
|
137
|
+
modality: Optional[str]
|
|
138
|
+
study_description: Optional[str]
|
|
139
|
+
scheduled_station_aetitle: Optional[str]
|
|
140
|
+
scheduled_start_date: Optional[str]
|
|
141
|
+
scheduled_start_time: Optional[str]
|
|
142
|
+
performing_physician: Optional[str]
|
|
143
|
+
procedure_description: Optional[str]
|
|
144
|
+
protocol_name: Optional[str]
|
|
145
|
+
station_name: Optional[str]
|
|
146
|
+
performed_procedure_step_status: Optional[str]
|
|
147
|
+
|
|
148
|
+
class Config:
|
|
149
|
+
from_attributes = True
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class WorklistItemCreate(BaseModel):
|
|
153
|
+
"""Model for creating worklist items."""
|
|
154
|
+
study_instance_uid: Optional[str] = None
|
|
155
|
+
patient_name: str
|
|
156
|
+
patient_id: str
|
|
157
|
+
patient_birth_date: Optional[str] = None
|
|
158
|
+
patient_sex: Optional[str] = None
|
|
159
|
+
patient_weight_lb: Optional[str] = "100"
|
|
160
|
+
accession_number: Optional[str] = None
|
|
161
|
+
referring_physician_name: Optional[str] = None
|
|
162
|
+
modality: Optional[str] = None
|
|
163
|
+
study_description: Optional[str] = None
|
|
164
|
+
scheduled_station_aetitle: Optional[str] = None
|
|
165
|
+
scheduled_start_date: Optional[str] = None
|
|
166
|
+
scheduled_start_time: Optional[str] = None
|
|
167
|
+
performing_physician: Optional[str] = None
|
|
168
|
+
procedure_description: Optional[str] = None
|
|
169
|
+
protocol_name: Optional[str] = None
|
|
170
|
+
station_name: Optional[str] = None
|
|
171
|
+
performed_procedure_step_status: str = "SCHEDULED"
|
|
172
|
+
|
|
173
|
+
@validator('performed_procedure_step_status')
|
|
174
|
+
def validate_status(cls, v):
|
|
175
|
+
allowed_statuses = ['SCHEDULED', 'IN_PROGRESS', 'COMPLETED', 'DISCONTINUED']
|
|
176
|
+
if v not in allowed_statuses:
|
|
177
|
+
raise ValueError(f'Status must be one of: {allowed_statuses}')
|
|
178
|
+
return v
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class WorklistItemUpdate(BaseModel):
|
|
182
|
+
"""Model for updating worklist items."""
|
|
183
|
+
patient_name: Optional[str] = None
|
|
184
|
+
patient_birth_date: Optional[str] = None
|
|
185
|
+
patient_sex: Optional[str] = None
|
|
186
|
+
patient_weight_lb: Optional[str] = None
|
|
187
|
+
referring_physician_name: Optional[str] = None
|
|
188
|
+
modality: Optional[str] = None
|
|
189
|
+
study_description: Optional[str] = None
|
|
190
|
+
scheduled_station_aetitle: Optional[str] = None
|
|
191
|
+
scheduled_start_date: Optional[str] = None
|
|
192
|
+
scheduled_start_time: Optional[str] = None
|
|
193
|
+
performing_physician: Optional[str] = None
|
|
194
|
+
procedure_description: Optional[str] = None
|
|
195
|
+
protocol_name: Optional[str] = None
|
|
196
|
+
station_name: Optional[str] = None
|
|
197
|
+
performed_procedure_step_status: Optional[str] = None
|
|
198
|
+
|
|
199
|
+
@validator('performed_procedure_step_status')
|
|
200
|
+
def validate_status(cls, v):
|
|
201
|
+
if v is not None:
|
|
202
|
+
allowed_statuses = ['SCHEDULED', 'IN_PROGRESS', 'COMPLETED', 'DISCONTINUED']
|
|
203
|
+
if v not in allowed_statuses:
|
|
204
|
+
raise ValueError(f'Status must be one of: {allowed_statuses}')
|
|
205
|
+
return v
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class UserResponse(BaseModel):
|
|
209
|
+
"""Response model for users."""
|
|
210
|
+
id: int
|
|
211
|
+
username: str
|
|
212
|
+
email: Optional[str]
|
|
213
|
+
full_name: Optional[str]
|
|
214
|
+
role: str
|
|
215
|
+
is_active: bool
|
|
216
|
+
created_at: datetime
|
|
217
|
+
last_login: Optional[datetime]
|
|
218
|
+
|
|
219
|
+
class Config:
|
|
220
|
+
from_attributes = True
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class UserCreate(BaseModel):
|
|
224
|
+
"""Model for creating users."""
|
|
225
|
+
username: str
|
|
226
|
+
email: Optional[str] = None
|
|
227
|
+
full_name: Optional[str] = None
|
|
228
|
+
password: str
|
|
229
|
+
role: str = "read"
|
|
230
|
+
|
|
231
|
+
@validator('role')
|
|
232
|
+
def validate_role(cls, v):
|
|
233
|
+
allowed_roles = ['admin', 'write', 'read']
|
|
234
|
+
if v not in allowed_roles:
|
|
235
|
+
raise ValueError(f'Role must be one of: {allowed_roles}')
|
|
236
|
+
return v
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class UserUpdate(BaseModel):
|
|
240
|
+
"""Model for updating users."""
|
|
241
|
+
email: Optional[str] = None
|
|
242
|
+
full_name: Optional[str] = None
|
|
243
|
+
password: Optional[str] = None
|
|
244
|
+
role: Optional[str] = None
|
|
245
|
+
is_active: Optional[bool] = None
|
|
246
|
+
|
|
247
|
+
@validator('role')
|
|
248
|
+
def validate_role(cls, v):
|
|
249
|
+
if v is not None:
|
|
250
|
+
allowed_roles = ['admin', 'write', 'read']
|
|
251
|
+
if v not in allowed_roles:
|
|
252
|
+
raise ValueError(f'Role must be one of: {allowed_roles}')
|
|
253
|
+
return v
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class Token(BaseModel):
|
|
257
|
+
"""Token response model."""
|
|
258
|
+
access_token: str
|
|
259
|
+
token_type: str
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class LoginRequest(BaseModel):
|
|
263
|
+
"""Login request model."""
|
|
264
|
+
username: str
|
|
265
|
+
password: str
|
|
266
|
+
access_token_expire_minutes: Optional[int] = None
|
|
267
|
+
|
|
268
|
+
@validator('access_token_expire_minutes')
|
|
269
|
+
def validate_expiry_minutes(cls, v):
|
|
270
|
+
if v is not None and v <= 0:
|
|
271
|
+
raise ValueError('access_token_expire_minutes must be a positive integer')
|
|
272
|
+
return v
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# Authentication dependency
|
|
276
|
+
async def get_current_user(
|
|
277
|
+
credentials: HTTPAuthorizationCredentials = Depends(security),
|
|
278
|
+
auth_db: Session = Depends(get_auth_db)
|
|
279
|
+
) -> User:
|
|
280
|
+
"""
|
|
281
|
+
Get current authenticated user from JWT token.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
credentials: HTTP Authorization credentials
|
|
285
|
+
auth_db: Authentication database session
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
User: Authenticated user object
|
|
289
|
+
|
|
290
|
+
Raises:
|
|
291
|
+
HTTPException: If authentication fails
|
|
292
|
+
"""
|
|
293
|
+
try:
|
|
294
|
+
token = credentials.credentials
|
|
295
|
+
payload = verify_token(token)
|
|
296
|
+
|
|
297
|
+
if payload is None:
|
|
298
|
+
raise HTTPException(
|
|
299
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
300
|
+
detail="Invalid authentication token",
|
|
301
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
username = payload.get("sub")
|
|
305
|
+
if username is None:
|
|
306
|
+
raise HTTPException(
|
|
307
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
308
|
+
detail="Invalid token payload",
|
|
309
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
user = auth_db.query(User).filter(User.username == username).first()
|
|
313
|
+
if user is None or not user.is_active:
|
|
314
|
+
raise HTTPException(
|
|
315
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
316
|
+
detail="User not found or inactive",
|
|
317
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
return user
|
|
321
|
+
|
|
322
|
+
except HTTPException:
|
|
323
|
+
raise
|
|
324
|
+
except Exception as e:
|
|
325
|
+
lgr.error(f"Authentication error: {e}")
|
|
326
|
+
raise HTTPException(
|
|
327
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
328
|
+
detail="Authentication failed",
|
|
329
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def require_permission(action: str, resource: str = "worklist"):
|
|
334
|
+
"""
|
|
335
|
+
Dependency factory for permission checking.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
action: Required action permission
|
|
339
|
+
resource: Resource type
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
Dependency function
|
|
343
|
+
"""
|
|
344
|
+
def permission_checker(current_user: User = Depends(get_current_user)) -> User:
|
|
345
|
+
if not current_user.has_permission(action, resource):
|
|
346
|
+
raise HTTPException(
|
|
347
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
348
|
+
detail=f"Insufficient permissions for {action} on {resource}"
|
|
349
|
+
)
|
|
350
|
+
return current_user
|
|
351
|
+
|
|
352
|
+
return permission_checker
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# Authentication endpoints
|
|
356
|
+
@app.post("/auth/login", response_model=Token)
|
|
357
|
+
async def login(
|
|
358
|
+
login_data: LoginRequest,
|
|
359
|
+
auth_db: Session = Depends(get_auth_db)
|
|
360
|
+
):
|
|
361
|
+
"""Authenticate user and return access token."""
|
|
362
|
+
try:
|
|
363
|
+
user = authenticate_user(auth_db, login_data.username, login_data.password)
|
|
364
|
+
|
|
365
|
+
if not user:
|
|
366
|
+
raise HTTPException(
|
|
367
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
368
|
+
detail="Invalid username or password",
|
|
369
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
expires_delta = None
|
|
373
|
+
if login_data.access_token_expire_minutes is not None:
|
|
374
|
+
expires_delta = timedelta(minutes=login_data.access_token_expire_minutes)
|
|
375
|
+
|
|
376
|
+
access_token = create_access_token(
|
|
377
|
+
data={"sub": user.username},
|
|
378
|
+
expires_delta=expires_delta
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
lgr.info(f"User {user.username} logged in successfully")
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
"access_token": access_token,
|
|
385
|
+
"token_type": "bearer"
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
except HTTPException:
|
|
389
|
+
raise
|
|
390
|
+
except Exception as e:
|
|
391
|
+
lgr.error(f"Login error: {e}")
|
|
392
|
+
raise HTTPException(
|
|
393
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
394
|
+
detail="Login failed"
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
# Worklist endpoints
|
|
399
|
+
@app.get("/worklist", response_model=List[WorklistItemResponse])
|
|
400
|
+
async def get_worklist_items(
|
|
401
|
+
status: Optional[List[str]] = Query(
|
|
402
|
+
default=["SCHEDULED", "IN_PROGRESS"],
|
|
403
|
+
description="Filter by procedure status (SCHEDULED, IN_PROGRESS, COMPLETED, DISCONTINUED)"
|
|
404
|
+
),
|
|
405
|
+
limit: int = Query(default=100, le=1000, description="Maximum number of items to return"),
|
|
406
|
+
offset: int = Query(default=0, ge=0, description="Number of items to skip"),
|
|
407
|
+
patient_id: Optional[str] = Query(default=None, description="Filter by patient ID"),
|
|
408
|
+
modality: Optional[str] = Query(default=None, description="Filter by modality"),
|
|
409
|
+
current_user: User = Depends(require_permission("read", "worklist")),
|
|
410
|
+
db: Session = Depends(get_api_db)
|
|
411
|
+
):
|
|
412
|
+
"""
|
|
413
|
+
Get worklist items with optional filtering.
|
|
414
|
+
|
|
415
|
+
Requires: read permission on worklist
|
|
416
|
+
"""
|
|
417
|
+
try:
|
|
418
|
+
query = db.query(WorklistItem)
|
|
419
|
+
|
|
420
|
+
# Apply filters
|
|
421
|
+
if status:
|
|
422
|
+
query = query.filter(WorklistItem.performed_procedure_step_status.in_(status))
|
|
423
|
+
|
|
424
|
+
if patient_id:
|
|
425
|
+
query = query.filter(WorklistItem.patient_id.ilike(f"%{patient_id}%"))
|
|
426
|
+
|
|
427
|
+
if modality:
|
|
428
|
+
query = query.filter(WorklistItem.modality == modality)
|
|
429
|
+
|
|
430
|
+
# Apply pagination
|
|
431
|
+
items = query.offset(offset).limit(limit).all()
|
|
432
|
+
|
|
433
|
+
lgr.info(f"User {current_user.username} retrieved {len(items)} worklist items")
|
|
434
|
+
|
|
435
|
+
return items
|
|
436
|
+
|
|
437
|
+
except Exception as e:
|
|
438
|
+
lgr.error(f"Error retrieving worklist items: {e}")
|
|
439
|
+
raise HTTPException(
|
|
440
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
441
|
+
detail="Failed to retrieve worklist items"
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
@app.post("/worklist", response_model=WorklistItemResponse)
|
|
446
|
+
async def create_worklist_item(
|
|
447
|
+
item_data: WorklistItemCreate,
|
|
448
|
+
current_user: User = Depends(require_permission("create", "worklist")),
|
|
449
|
+
db: Session = Depends(get_api_db)
|
|
450
|
+
):
|
|
451
|
+
"""
|
|
452
|
+
Create a new worklist item.
|
|
453
|
+
|
|
454
|
+
Requires: write permission on worklist
|
|
455
|
+
"""
|
|
456
|
+
try:
|
|
457
|
+
# Generate study_instance_uid if not provided
|
|
458
|
+
if not item_data.study_instance_uid:
|
|
459
|
+
import uuid
|
|
460
|
+
item_data.study_instance_uid = f"1.2.840.10008.3.1.2.3.4.{uuid.uuid4().int}"
|
|
461
|
+
|
|
462
|
+
# Create database object
|
|
463
|
+
db_item = WorklistItem(**item_data.dict())
|
|
464
|
+
|
|
465
|
+
db.add(db_item)
|
|
466
|
+
db.commit()
|
|
467
|
+
db.refresh(db_item)
|
|
468
|
+
|
|
469
|
+
lgr.info(f"User {current_user.username} created worklist item {db_item.id}")
|
|
470
|
+
|
|
471
|
+
return db_item
|
|
472
|
+
|
|
473
|
+
except Exception as e:
|
|
474
|
+
lgr.error(f"Error creating worklist item: {e}")
|
|
475
|
+
db.rollback()
|
|
476
|
+
raise HTTPException(
|
|
477
|
+
status_code=500,
|
|
478
|
+
detail="Failed to create worklist item"
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
@app.put("/worklist/{item_id}", response_model=WorklistItemResponse)
|
|
483
|
+
async def update_worklist_item(
|
|
484
|
+
item_id: int,
|
|
485
|
+
item_data: WorklistItemUpdate,
|
|
486
|
+
current_user: User = Depends(require_permission("update", "worklist")),
|
|
487
|
+
db: Session = Depends(get_api_db)
|
|
488
|
+
):
|
|
489
|
+
"""
|
|
490
|
+
Update an existing worklist item.
|
|
491
|
+
|
|
492
|
+
Requires: write permission on worklist
|
|
493
|
+
"""
|
|
494
|
+
try:
|
|
495
|
+
db_item = db.query(WorklistItem).filter(WorklistItem.id == item_id).first()
|
|
496
|
+
|
|
497
|
+
if not db_item:
|
|
498
|
+
raise HTTPException(
|
|
499
|
+
status_code=404,
|
|
500
|
+
detail=f"Worklist item {item_id} not found"
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
# Update fields that are provided
|
|
504
|
+
update_data = item_data.dict(exclude_unset=True)
|
|
505
|
+
for field, value in update_data.items():
|
|
506
|
+
setattr(db_item, field, value)
|
|
507
|
+
|
|
508
|
+
db.commit()
|
|
509
|
+
db.refresh(db_item)
|
|
510
|
+
|
|
511
|
+
lgr.info(f"User {current_user.username} updated worklist item {item_id}")
|
|
512
|
+
|
|
513
|
+
return db_item
|
|
514
|
+
|
|
515
|
+
except HTTPException:
|
|
516
|
+
raise
|
|
517
|
+
except Exception as e:
|
|
518
|
+
lgr.error(f"Error updating worklist item {item_id}: {e}")
|
|
519
|
+
db.rollback()
|
|
520
|
+
raise HTTPException(
|
|
521
|
+
status_code=500,
|
|
522
|
+
detail="Failed to update worklist item"
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
@app.delete("/worklist/{item_id}")
|
|
527
|
+
async def delete_worklist_item(
|
|
528
|
+
item_id: int,
|
|
529
|
+
current_user: User = Depends(require_permission("delete", "worklist")),
|
|
530
|
+
db: Session = Depends(get_api_db)
|
|
531
|
+
):
|
|
532
|
+
"""
|
|
533
|
+
Delete a worklist item.
|
|
534
|
+
|
|
535
|
+
Requires: write permission on worklist
|
|
536
|
+
"""
|
|
537
|
+
try:
|
|
538
|
+
db_item = db.query(WorklistItem).filter(WorklistItem.id == item_id).first()
|
|
539
|
+
|
|
540
|
+
if not db_item:
|
|
541
|
+
raise HTTPException(
|
|
542
|
+
status_code=404,
|
|
543
|
+
detail=f"Worklist item {item_id} not found"
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
db.delete(db_item)
|
|
547
|
+
db.commit()
|
|
548
|
+
|
|
549
|
+
lgr.info(f"User {current_user.username} deleted worklist item {item_id}")
|
|
550
|
+
|
|
551
|
+
return {"message": f"Worklist item {item_id} deleted successfully"}
|
|
552
|
+
|
|
553
|
+
except HTTPException:
|
|
554
|
+
raise
|
|
555
|
+
except Exception as e:
|
|
556
|
+
lgr.error(f"Error deleting worklist item {item_id}: {e}")
|
|
557
|
+
db.rollback()
|
|
558
|
+
raise HTTPException(
|
|
559
|
+
status_code=500,
|
|
560
|
+
detail="Failed to delete worklist item"
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
# User management endpoints (admin only)
|
|
565
|
+
@app.get("/users", response_model=List[UserResponse])
|
|
566
|
+
async def get_users(
|
|
567
|
+
current_user: User = Depends(require_permission("read", "users")),
|
|
568
|
+
auth_db: Session = Depends(get_auth_db)
|
|
569
|
+
):
|
|
570
|
+
"""
|
|
571
|
+
Get list of all users.
|
|
572
|
+
|
|
573
|
+
Requires: admin role
|
|
574
|
+
"""
|
|
575
|
+
try:
|
|
576
|
+
users = auth_db.query(User).all()
|
|
577
|
+
|
|
578
|
+
lgr.info(f"Admin {current_user.username} retrieved user list")
|
|
579
|
+
|
|
580
|
+
return users
|
|
581
|
+
|
|
582
|
+
except Exception as e:
|
|
583
|
+
lgr.error(f"Error retrieving users: {e}")
|
|
584
|
+
raise HTTPException(
|
|
585
|
+
status_code=500,
|
|
586
|
+
detail="Failed to retrieve users"
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
@app.post("/users", response_model=UserResponse)
|
|
591
|
+
async def create_user(
|
|
592
|
+
user_data: UserCreate,
|
|
593
|
+
current_user: User = Depends(require_permission("create", "users")),
|
|
594
|
+
auth_db: Session = Depends(get_auth_db)
|
|
595
|
+
):
|
|
596
|
+
"""
|
|
597
|
+
Create a new user.
|
|
598
|
+
|
|
599
|
+
Requires: admin role
|
|
600
|
+
"""
|
|
601
|
+
try:
|
|
602
|
+
# Check if username already exists
|
|
603
|
+
existing_user = auth_db.query(User).filter(User.username == user_data.username).first()
|
|
604
|
+
if existing_user:
|
|
605
|
+
raise HTTPException(
|
|
606
|
+
status_code=400,
|
|
607
|
+
detail="Username already exists"
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
# Hash password
|
|
611
|
+
hashed_password = get_password_hash(user_data.password)
|
|
612
|
+
|
|
613
|
+
# Create user
|
|
614
|
+
db_user = User(
|
|
615
|
+
username=user_data.username,
|
|
616
|
+
email=user_data.email,
|
|
617
|
+
full_name=user_data.full_name,
|
|
618
|
+
hashed_password=hashed_password,
|
|
619
|
+
role=UserRole(user_data.role),
|
|
620
|
+
is_active=True,
|
|
621
|
+
created_at=datetime.utcnow(),
|
|
622
|
+
created_by=current_user.id
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
auth_db.add(db_user)
|
|
626
|
+
auth_db.commit()
|
|
627
|
+
auth_db.refresh(db_user)
|
|
628
|
+
|
|
629
|
+
lgr.info(f"Admin {current_user.username} created user {db_user.username}")
|
|
630
|
+
|
|
631
|
+
return db_user
|
|
632
|
+
|
|
633
|
+
except HTTPException:
|
|
634
|
+
raise
|
|
635
|
+
except Exception as e:
|
|
636
|
+
lgr.error(f"Error creating user: {e}")
|
|
637
|
+
auth_db.rollback()
|
|
638
|
+
raise HTTPException(
|
|
639
|
+
status_code=500,
|
|
640
|
+
detail="Failed to create user"
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
@app.put("/users/{user_id}", response_model=UserResponse)
|
|
645
|
+
async def update_user(
|
|
646
|
+
user_id: int,
|
|
647
|
+
user_data: UserUpdate,
|
|
648
|
+
current_user: User = Depends(require_permission("update", "users")),
|
|
649
|
+
auth_db: Session = Depends(get_auth_db)
|
|
650
|
+
):
|
|
651
|
+
"""
|
|
652
|
+
Update an existing user.
|
|
653
|
+
|
|
654
|
+
Requires: admin role
|
|
655
|
+
"""
|
|
656
|
+
try:
|
|
657
|
+
db_user = auth_db.query(User).filter(User.id == user_id).first()
|
|
658
|
+
|
|
659
|
+
if not db_user:
|
|
660
|
+
raise HTTPException(
|
|
661
|
+
status_code=404,
|
|
662
|
+
detail=f"User {user_id} not found"
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
# Update fields that are provided
|
|
666
|
+
update_data = user_data.dict(exclude_unset=True)
|
|
667
|
+
|
|
668
|
+
# Handle password hashing separately
|
|
669
|
+
if 'password' in update_data:
|
|
670
|
+
update_data['hashed_password'] = get_password_hash(update_data.pop('password'))
|
|
671
|
+
|
|
672
|
+
# Handle role conversion
|
|
673
|
+
if 'role' in update_data:
|
|
674
|
+
update_data['role'] = UserRole(update_data['role'])
|
|
675
|
+
|
|
676
|
+
for field, value in update_data.items():
|
|
677
|
+
setattr(db_user, field, value)
|
|
678
|
+
|
|
679
|
+
auth_db.commit()
|
|
680
|
+
auth_db.refresh(db_user)
|
|
681
|
+
|
|
682
|
+
lgr.info(f"Admin {current_user.username} updated user {db_user.username}")
|
|
683
|
+
|
|
684
|
+
return db_user
|
|
685
|
+
|
|
686
|
+
except HTTPException:
|
|
687
|
+
raise
|
|
688
|
+
except Exception as e:
|
|
689
|
+
lgr.error(f"Error updating user {user_id}: {e}")
|
|
690
|
+
auth_db.rollback()
|
|
691
|
+
raise HTTPException(
|
|
692
|
+
status_code=500,
|
|
693
|
+
detail="Failed to update user"
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
@app.delete("/users/{user_id}")
|
|
698
|
+
async def delete_user(
|
|
699
|
+
user_id: int,
|
|
700
|
+
current_user: User = Depends(require_permission("delete", "users")),
|
|
701
|
+
auth_db: Session = Depends(get_auth_db)
|
|
702
|
+
):
|
|
703
|
+
"""
|
|
704
|
+
Delete a user.
|
|
705
|
+
|
|
706
|
+
Requires: admin role
|
|
707
|
+
"""
|
|
708
|
+
try:
|
|
709
|
+
if user_id == current_user.id:
|
|
710
|
+
raise HTTPException(
|
|
711
|
+
status_code=400,
|
|
712
|
+
detail="Cannot delete your own account"
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
db_user = auth_db.query(User).filter(User.id == user_id).first()
|
|
716
|
+
|
|
717
|
+
if not db_user:
|
|
718
|
+
raise HTTPException(
|
|
719
|
+
status_code=404,
|
|
720
|
+
detail=f"User {user_id} not found"
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
auth_db.delete(db_user)
|
|
724
|
+
auth_db.commit()
|
|
725
|
+
|
|
726
|
+
lgr.info(f"Admin {current_user.username} deleted user {db_user.username}")
|
|
727
|
+
|
|
728
|
+
return {"message": f"User {db_user.username} deleted successfully"}
|
|
729
|
+
|
|
730
|
+
except HTTPException:
|
|
731
|
+
raise
|
|
732
|
+
except Exception as e:
|
|
733
|
+
lgr.error(f"Error deleting user {user_id}: {e}")
|
|
734
|
+
auth_db.rollback()
|
|
735
|
+
raise HTTPException(
|
|
736
|
+
status_code=500,
|
|
737
|
+
detail="Failed to delete user"
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
# Health check endpoint
|
|
742
|
+
@app.get("/health")
|
|
743
|
+
async def health_check():
|
|
744
|
+
"""Health check endpoint."""
|
|
745
|
+
return {"status": "healthy", "timestamp": datetime.utcnow()}
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
# Initialize authentication database on startup
|
|
749
|
+
@app.on_event("startup")
|
|
750
|
+
async def startup_event():
|
|
751
|
+
"""Initialize authentication system on startup."""
|
|
752
|
+
try:
|
|
753
|
+
# Try to load configuration to get users_db_path
|
|
754
|
+
# This will work when started via CLI, but fallback gracefully for direct API startup
|
|
755
|
+
import os
|
|
756
|
+
users_db_path = os.getenv("USERS_DB_PATH") # Set by CLI when config is loaded
|
|
757
|
+
|
|
758
|
+
init_auth_database(users_db_path)
|
|
759
|
+
create_initial_admin_user(users_db_path)
|
|
760
|
+
lgr.info("Pylantir API server started successfully")
|
|
761
|
+
except Exception as e:
|
|
762
|
+
lgr.error(f"Failed to initialize API server: {e}")
|
|
763
|
+
raise
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
if __name__ == "__main__":
|
|
767
|
+
import uvicorn
|
|
768
|
+
lgr.info("Starting Pylantir API server...")
|
|
769
|
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|