Spaces:
Running on Zero
Running on Zero
Upload 2 files
Browse files- app.py +137 -151
- report_workflow.py +188 -484
app.py
CHANGED
|
@@ -10,7 +10,13 @@ import gradio as gr
|
|
| 10 |
import spaces
|
| 11 |
|
| 12 |
import database
|
| 13 |
-
from report_workflow import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
from inference import classes_info, health_info, predict_bytes, root_info
|
| 15 |
|
| 16 |
logging.basicConfig(
|
|
@@ -19,16 +25,19 @@ logging.basicConfig(
|
|
| 19 |
)
|
| 20 |
logger = logging.getLogger("featurex-zerogpu-server")
|
| 21 |
|
| 22 |
-
SERVER_VERSION = "3.5.
|
| 23 |
MAX_UPLOAD = 15 * 1024 * 1024
|
| 24 |
|
|
|
|
| 25 |
def _runtime_info():
|
| 26 |
info = {}
|
| 27 |
try:
|
| 28 |
import torch
|
| 29 |
info["torch"] = torch.__version__
|
| 30 |
info["cuda_available"] = bool(torch.cuda.is_available())
|
| 31 |
-
info["cuda_device"] =
|
|
|
|
|
|
|
| 32 |
except Exception as exc:
|
| 33 |
info["torch_error"] = str(exc)
|
| 34 |
try:
|
|
@@ -38,23 +47,19 @@ def _runtime_info():
|
|
| 38 |
info["transformers_error"] = str(exc)
|
| 39 |
info["janus_enabled"] = os.environ.get("JANUS_ENABLED", "true")
|
| 40 |
info["janus_model_id"] = os.environ.get("JANUS_MODEL_ID", "ZrH42/Janus-Pro-CXR-Final")
|
| 41 |
-
info["model_path"] = os.environ.get(
|
|
|
|
|
|
|
|
|
|
| 42 |
info["model_file_exists"] = os.path.isfile(info["model_path"])
|
| 43 |
return info
|
| 44 |
|
| 45 |
|
| 46 |
-
|
| 47 |
def _json(payload: Any) -> str:
|
| 48 |
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
| 49 |
|
| 50 |
|
| 51 |
def _read_uploaded_image(image_path: str) -> tuple[bytes, str]:
|
| 52 |
-
"""Read a real uploaded image file supplied by Gradio.
|
| 53 |
-
|
| 54 |
-
The browser/phone uploads the binary file to Gradio; the analysis
|
| 55 |
-
function receives the temporary server-side file path. No base64 is
|
| 56 |
-
used in the public UI or analysis function.
|
| 57 |
-
"""
|
| 58 |
if not image_path:
|
| 59 |
raise ValueError("Please upload an X-ray image.")
|
| 60 |
|
|
@@ -168,8 +173,6 @@ def _build_unified(result: Dict[str, Any]) -> Dict[str, Any]:
|
|
| 168 |
"quality_metrics": quality,
|
| 169 |
"heatmap_stats": result.get("heatmap_stats", {}),
|
| 170 |
"model_info": result.get("model_info", {}),
|
| 171 |
-
# Only Storage URLs are returned. Raw image/base64 payloads are never
|
| 172 |
-
# exposed through the public API response.
|
| 173 |
"visualizations": {
|
| 174 |
**((result.get("database") or {}).get("visualization_urls") or {}),
|
| 175 |
},
|
|
@@ -183,7 +186,11 @@ def _build_unified(result: Dict[str, Any]) -> Dict[str, Any]:
|
|
| 183 |
"recommendations": selected.get("recommendations", []),
|
| 184 |
"raw_text": "\n".join(
|
| 185 |
str(x)
|
| 186 |
-
for x in [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
if x
|
| 188 |
),
|
| 189 |
"structured": selected,
|
|
@@ -194,17 +201,16 @@ def _build_unified(result: Dict[str, Any]) -> Dict[str, Any]:
|
|
| 194 |
"gradcam": {
|
| 195 |
"enabled": True,
|
| 196 |
"heatmap_stats": result.get("heatmap_stats", {}),
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
**((result.get("database") or {}).get("visualization_urls") or {}),
|
| 201 |
-
},
|
| 202 |
"visualization_keys": list((result.get("visualizations") or {}).keys()),
|
| 203 |
},
|
| 204 |
"discrepancies": {"flags": [], "count": 0},
|
| 205 |
"safety": {
|
| 206 |
"wording_flags": confidence_flags,
|
| 207 |
-
"manual_review_required": bool(safety_flags)
|
|
|
|
| 208 |
"reasons": safety_flags,
|
| 209 |
},
|
| 210 |
"database": result.get("database", {}),
|
|
@@ -234,15 +240,21 @@ def analyze(
|
|
| 234 |
) -> str:
|
| 235 |
request_id = str(uuid.uuid4())
|
| 236 |
try:
|
| 237 |
-
# Do not trust client-supplied user_id/role/department. Firebase ID token
|
| 238 |
-
# is verified server-side; role metadata comes from verified claims.
|
| 239 |
identity = _identity(firebase_token, user_id)
|
| 240 |
raw, filename = _read_uploaded_image(image_path)
|
| 241 |
language = (language or "ar").strip().lower()
|
| 242 |
if language not in {"ar", "en"}:
|
| 243 |
language = "ar"
|
| 244 |
patient, _client_user = _metadata(
|
| 245 |
-
patient_name,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
)
|
| 247 |
user = {
|
| 248 |
"user_id": identity["uid"],
|
|
@@ -277,17 +289,15 @@ def analyze(
|
|
| 277 |
patient=patient,
|
| 278 |
user=user,
|
| 279 |
)
|
| 280 |
-
db_info.update(
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
"storage_saved"
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
}
|
| 290 |
-
)
|
| 291 |
if saved.get("reason"):
|
| 292 |
db_info["reason"] = saved["reason"]
|
| 293 |
if saved.get("error"):
|
|
@@ -295,19 +305,15 @@ def analyze(
|
|
| 295 |
db_info["error_type"] = saved.get("error_type")
|
| 296 |
except Exception as exc:
|
| 297 |
logger.exception("SUPABASE STORAGE/DATABASE SAVE FAILED request_id=%s", request_id)
|
| 298 |
-
db_info.update(
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
}
|
| 305 |
-
)
|
| 306 |
|
| 307 |
result["database"] = db_info
|
| 308 |
|
| 309 |
-
# A patient may submit an analysis request, but cannot receive diagnostic
|
| 310 |
-
# content until the protected get_report endpoint confirms approval.
|
| 311 |
if identity["role"] == "patient":
|
| 312 |
return _json({
|
| 313 |
"success": True,
|
|
@@ -320,65 +326,60 @@ def analyze(
|
|
| 320 |
"request_id": request_id,
|
| 321 |
})
|
| 322 |
|
| 323 |
-
# Visualization bytes are server-side only. They are uploaded by
|
| 324 |
-
# database.save_analysis(); the public response contains Storage URLs only.
|
| 325 |
result["visualizations"] = {}
|
| 326 |
return _json(_build_unified(result))
|
| 327 |
|
| 328 |
except PermissionError as exc:
|
| 329 |
logger.warning("ANALYZE AUTH REJECTED request_id=%s reason=%s", request_id, str(exc))
|
| 330 |
-
return _json({
|
| 331 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 332 |
except ValueError as exc:
|
| 333 |
logger.warning("ANALYZE REJECTED request_id=%s reason=%s", request_id, str(exc))
|
| 334 |
-
return _json(
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
}
|
| 342 |
-
)
|
| 343 |
except Exception as exc:
|
| 344 |
logger.exception("ANALYZE ERROR request_id=%s", request_id)
|
| 345 |
-
return _json(
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
}
|
| 353 |
-
)
|
| 354 |
|
| 355 |
|
| 356 |
@spaces.GPU(duration=30)
|
| 357 |
def gpu_health() -> str:
|
| 358 |
info = health_info()
|
| 359 |
-
info.update(
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
}
|
| 367 |
-
)
|
| 368 |
return _json(info)
|
| 369 |
|
| 370 |
|
| 371 |
def health() -> str:
|
| 372 |
info = root_info()
|
| 373 |
-
info.update(
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
}
|
| 381 |
-
)
|
| 382 |
return _json(info)
|
| 383 |
|
| 384 |
|
|
@@ -387,12 +388,6 @@ def classes() -> str:
|
|
| 387 |
|
| 388 |
|
| 389 |
def db_health() -> str:
|
| 390 |
-
"""
|
| 391 |
-
CPU-only Neon/R2 diagnostic.
|
| 392 |
-
|
| 393 |
-
This endpoint intentionally runs without @spaces.GPU so Storage/DB can be
|
| 394 |
-
tested before spending ZeroGPU time on EfficientNet/Janus inference.
|
| 395 |
-
"""
|
| 396 |
try:
|
| 397 |
result = database.health_check()
|
| 398 |
except Exception as exc:
|
|
@@ -406,45 +401,32 @@ def db_health() -> str:
|
|
| 406 |
return _json(result)
|
| 407 |
|
| 408 |
|
| 409 |
-
# ---------------------------------------------------------------------------
|
| 410 |
-
# Gradio 6 Blocks API / ZeroGPU
|
| 411 |
-
# ---------------------------------------------------------------------------
|
| 412 |
-
# Hugging Face currently injects the Space-selected Gradio runtime (the
|
| 413 |
-
# observed runtime is Gradio 6.x). Do not pin Gradio, spaces, or
|
| 414 |
-
# huggingface-hub in requirements.txt.
|
| 415 |
-
#
|
| 416 |
-
# The public Android endpoint is the current Gradio queued API:
|
| 417 |
-
# POST /gradio_api/call/analyze
|
| 418 |
-
# GET /gradio_api/call/analyze/<event_id>
|
| 419 |
-
#
|
| 420 |
-
# SSR is disabled deliberately. The API does not need the experimental SSR
|
| 421 |
-
# frontend, and this removes one unnecessary HTTP/proxy layer for an
|
| 422 |
-
# API-first medical inference Space.
|
| 423 |
-
|
| 424 |
def _health_endpoint() -> str:
|
| 425 |
-
"""Return a lightweight server health JSON document."""
|
| 426 |
info = root_info()
|
| 427 |
-
info.update(
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
"
|
| 435 |
-
|
| 436 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 437 |
return _json(info)
|
| 438 |
|
| 439 |
|
| 440 |
def _classes_endpoint() -> str:
|
| 441 |
-
"""Return the supported chest X-ray classification classes as JSON."""
|
| 442 |
return _json(classes_info())
|
| 443 |
|
| 444 |
|
| 445 |
-
# A small Gradio UI is intentionally kept here. The API event is the actual
|
| 446 |
-
# integration point for Android. The browser/mobile UI uses a real Gradio file upload. The analysis function
|
| 447 |
-
# receives a temporary server-side filepath; no base64 image field is used.
|
| 448 |
with gr.Blocks(
|
| 449 |
title="FeatureX Clinical AI — ZeroGPU",
|
| 450 |
analytics_enabled=False,
|
|
@@ -462,11 +444,7 @@ with gr.Blocks(
|
|
| 462 |
format="png",
|
| 463 |
height=360,
|
| 464 |
)
|
| 465 |
-
language = gr.Radio(
|
| 466 |
-
["ar", "en"],
|
| 467 |
-
value="ar",
|
| 468 |
-
label="Report Language",
|
| 469 |
-
)
|
| 470 |
|
| 471 |
with gr.Row():
|
| 472 |
patient_name = gr.Textbox(label="patient_name")
|
|
@@ -485,7 +463,6 @@ with gr.Blocks(
|
|
| 485 |
analyze_button = gr.Button("Analyze X-Ray", variant="primary")
|
| 486 |
analyze_output = gr.Textbox(label="JSON response", lines=20)
|
| 487 |
|
| 488 |
-
# This is the public endpoint consumed by FeatureX Android.
|
| 489 |
analyze_button.click(
|
| 490 |
analyze,
|
| 491 |
inputs=[
|
|
@@ -510,41 +487,28 @@ with gr.Blocks(
|
|
| 510 |
health_button = gr.Button("Health")
|
| 511 |
health_output = gr.Textbox(label="Health JSON")
|
| 512 |
health_button.click(
|
| 513 |
-
_health_endpoint,
|
| 514 |
-
inputs=[],
|
| 515 |
-
outputs=health_output,
|
| 516 |
-
api_name="health",
|
| 517 |
)
|
| 518 |
|
| 519 |
classes_button = gr.Button("Classes")
|
| 520 |
classes_output = gr.Textbox(label="Classes JSON")
|
| 521 |
classes_button.click(
|
| 522 |
-
_classes_endpoint,
|
| 523 |
-
inputs=[],
|
| 524 |
-
outputs=classes_output,
|
| 525 |
-
api_name="classes",
|
| 526 |
)
|
| 527 |
|
| 528 |
gpu_health_button = gr.Button("GPU Health")
|
| 529 |
gpu_health_output = gr.Textbox(label="GPU Health JSON")
|
| 530 |
gpu_health_button.click(
|
| 531 |
-
gpu_health,
|
| 532 |
-
inputs=[],
|
| 533 |
-
outputs=gpu_health_output,
|
| 534 |
-
api_name="gpu_health",
|
| 535 |
)
|
| 536 |
|
| 537 |
db_health_button = gr.Button("Database / Storage Health")
|
| 538 |
db_health_output = gr.Textbox(label="Database / Storage Health JSON", lines=12)
|
| 539 |
db_health_button.click(
|
| 540 |
-
db_health,
|
| 541 |
-
inputs=[],
|
| 542 |
-
outputs=db_health_output,
|
| 543 |
-
api_name="db_health",
|
| 544 |
)
|
| 545 |
|
| 546 |
-
# Protected report workflow APIs
|
| 547 |
-
# server-side; client-supplied role/user_id fields are never authorization proof.
|
| 548 |
report_user_id = gr.Textbox(visible=False)
|
| 549 |
report_token = gr.Textbox(visible=False)
|
| 550 |
report_id = gr.Textbox(visible=False)
|
|
@@ -554,27 +518,49 @@ with gr.Blocks(
|
|
| 554 |
list_reports_button = gr.Button("List Reports API", visible=False)
|
| 555 |
list_reports_output = gr.Textbox(visible=False)
|
| 556 |
list_reports_button.click(
|
| 557 |
-
list_reports_api,
|
| 558 |
-
|
|
|
|
|
|
|
| 559 |
)
|
| 560 |
|
| 561 |
get_report_button = gr.Button("Get Report API", visible=False)
|
| 562 |
get_report_output = gr.Textbox(visible=False)
|
| 563 |
get_report_button.click(
|
| 564 |
-
get_report_api,
|
| 565 |
-
|
|
|
|
|
|
|
| 566 |
)
|
| 567 |
|
| 568 |
review_report_button = gr.Button("Review Report API", visible=False)
|
| 569 |
review_report_output = gr.Textbox(visible=False)
|
| 570 |
review_report_button.click(
|
| 571 |
-
review_report_api,
|
| 572 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 573 |
)
|
| 574 |
|
|
|
|
| 575 |
if __name__ == "__main__":
|
| 576 |
demo.launch(
|
| 577 |
show_error=True,
|
| 578 |
max_file_size="15mb",
|
| 579 |
ssr_mode=False,
|
| 580 |
-
)
|
|
|
|
| 10 |
import spaces
|
| 11 |
|
| 12 |
import database
|
| 13 |
+
from report_workflow import (
|
| 14 |
+
list_reports_api,
|
| 15 |
+
get_report_api,
|
| 16 |
+
review_report_api,
|
| 17 |
+
update_report_content_api,
|
| 18 |
+
_identity,
|
| 19 |
+
)
|
| 20 |
from inference import classes_info, health_info, predict_bytes, root_info
|
| 21 |
|
| 22 |
logging.basicConfig(
|
|
|
|
| 25 |
)
|
| 26 |
logger = logging.getLogger("featurex-zerogpu-server")
|
| 27 |
|
| 28 |
+
SERVER_VERSION = "3.5.1-authenticated-workflow-status"
|
| 29 |
MAX_UPLOAD = 15 * 1024 * 1024
|
| 30 |
|
| 31 |
+
|
| 32 |
def _runtime_info():
|
| 33 |
info = {}
|
| 34 |
try:
|
| 35 |
import torch
|
| 36 |
info["torch"] = torch.__version__
|
| 37 |
info["cuda_available"] = bool(torch.cuda.is_available())
|
| 38 |
+
info["cuda_device"] = (
|
| 39 |
+
torch.cuda.get_device_name(0) if torch.cuda.is_available() else None
|
| 40 |
+
)
|
| 41 |
except Exception as exc:
|
| 42 |
info["torch_error"] = str(exc)
|
| 43 |
try:
|
|
|
|
| 47 |
info["transformers_error"] = str(exc)
|
| 48 |
info["janus_enabled"] = os.environ.get("JANUS_ENABLED", "true")
|
| 49 |
info["janus_model_id"] = os.environ.get("JANUS_MODEL_ID", "ZrH42/Janus-Pro-CXR-Final")
|
| 50 |
+
info["model_path"] = os.environ.get(
|
| 51 |
+
"MODEL_PATH",
|
| 52 |
+
os.path.join(os.path.dirname(os.path.abspath(__file__)), "best_efficientnet.pth"),
|
| 53 |
+
)
|
| 54 |
info["model_file_exists"] = os.path.isfile(info["model_path"])
|
| 55 |
return info
|
| 56 |
|
| 57 |
|
|
|
|
| 58 |
def _json(payload: Any) -> str:
|
| 59 |
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
| 60 |
|
| 61 |
|
| 62 |
def _read_uploaded_image(image_path: str) -> tuple[bytes, str]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
if not image_path:
|
| 64 |
raise ValueError("Please upload an X-ray image.")
|
| 65 |
|
|
|
|
| 173 |
"quality_metrics": quality,
|
| 174 |
"heatmap_stats": result.get("heatmap_stats", {}),
|
| 175 |
"model_info": result.get("model_info", {}),
|
|
|
|
|
|
|
| 176 |
"visualizations": {
|
| 177 |
**((result.get("database") or {}).get("visualization_urls") or {}),
|
| 178 |
},
|
|
|
|
| 186 |
"recommendations": selected.get("recommendations", []),
|
| 187 |
"raw_text": "\n".join(
|
| 188 |
str(x)
|
| 189 |
+
for x in [
|
| 190 |
+
selected.get("title"),
|
| 191 |
+
selected.get("findings"),
|
| 192 |
+
selected.get("impression"),
|
| 193 |
+
]
|
| 194 |
if x
|
| 195 |
),
|
| 196 |
"structured": selected,
|
|
|
|
| 201 |
"gradcam": {
|
| 202 |
"enabled": True,
|
| 203 |
"heatmap_stats": result.get("heatmap_stats", {}),
|
| 204 |
+
"visualizations": {
|
| 205 |
+
**((result.get("database") or {}).get("visualization_urls") or {}),
|
| 206 |
+
},
|
|
|
|
|
|
|
| 207 |
"visualization_keys": list((result.get("visualizations") or {}).keys()),
|
| 208 |
},
|
| 209 |
"discrepancies": {"flags": [], "count": 0},
|
| 210 |
"safety": {
|
| 211 |
"wording_flags": confidence_flags,
|
| 212 |
+
"manual_review_required": bool(safety_flags)
|
| 213 |
+
or bool((result.get("report_integrity") or {}).get("manual_review_required")),
|
| 214 |
"reasons": safety_flags,
|
| 215 |
},
|
| 216 |
"database": result.get("database", {}),
|
|
|
|
| 240 |
) -> str:
|
| 241 |
request_id = str(uuid.uuid4())
|
| 242 |
try:
|
|
|
|
|
|
|
| 243 |
identity = _identity(firebase_token, user_id)
|
| 244 |
raw, filename = _read_uploaded_image(image_path)
|
| 245 |
language = (language or "ar").strip().lower()
|
| 246 |
if language not in {"ar", "en"}:
|
| 247 |
language = "ar"
|
| 248 |
patient, _client_user = _metadata(
|
| 249 |
+
patient_name,
|
| 250 |
+
birth_date,
|
| 251 |
+
gender,
|
| 252 |
+
age,
|
| 253 |
+
user_id,
|
| 254 |
+
username,
|
| 255 |
+
department,
|
| 256 |
+
role,
|
| 257 |
+
supervisor_id,
|
| 258 |
)
|
| 259 |
user = {
|
| 260 |
"user_id": identity["uid"],
|
|
|
|
| 289 |
patient=patient,
|
| 290 |
user=user,
|
| 291 |
)
|
| 292 |
+
db_info.update({
|
| 293 |
+
"saved": bool(saved.get("saved", False)),
|
| 294 |
+
"analysis_id": saved.get("analysis_id", result["analysis_id"]),
|
| 295 |
+
"original_image_url": saved.get("original_image_url"),
|
| 296 |
+
"visualization_urls": saved.get("visualization_urls", {}),
|
| 297 |
+
"storage_saved": bool(
|
| 298 |
+
saved.get("storage_saved", saved.get("original_image_url"))
|
| 299 |
+
),
|
| 300 |
+
})
|
|
|
|
|
|
|
| 301 |
if saved.get("reason"):
|
| 302 |
db_info["reason"] = saved["reason"]
|
| 303 |
if saved.get("error"):
|
|
|
|
| 305 |
db_info["error_type"] = saved.get("error_type")
|
| 306 |
except Exception as exc:
|
| 307 |
logger.exception("SUPABASE STORAGE/DATABASE SAVE FAILED request_id=%s", request_id)
|
| 308 |
+
db_info.update({
|
| 309 |
+
"saved": False,
|
| 310 |
+
"storage_saved": False,
|
| 311 |
+
"error": str(exc),
|
| 312 |
+
"error_type": type(exc).__name__,
|
| 313 |
+
})
|
|
|
|
|
|
|
| 314 |
|
| 315 |
result["database"] = db_info
|
| 316 |
|
|
|
|
|
|
|
| 317 |
if identity["role"] == "patient":
|
| 318 |
return _json({
|
| 319 |
"success": True,
|
|
|
|
| 326 |
"request_id": request_id,
|
| 327 |
})
|
| 328 |
|
|
|
|
|
|
|
| 329 |
result["visualizations"] = {}
|
| 330 |
return _json(_build_unified(result))
|
| 331 |
|
| 332 |
except PermissionError as exc:
|
| 333 |
logger.warning("ANALYZE AUTH REJECTED request_id=%s reason=%s", request_id, str(exc))
|
| 334 |
+
return _json({
|
| 335 |
+
"success": False,
|
| 336 |
+
"status": "unauthorized",
|
| 337 |
+
"request_id": request_id,
|
| 338 |
+
"error": "Authentication required",
|
| 339 |
+
"message": "يرجى تسجيل الدخول مجددًا ثم المحاولة.",
|
| 340 |
+
})
|
| 341 |
except ValueError as exc:
|
| 342 |
logger.warning("ANALYZE REJECTED request_id=%s reason=%s", request_id, str(exc))
|
| 343 |
+
return _json({
|
| 344 |
+
"success": False,
|
| 345 |
+
"status": "rejected",
|
| 346 |
+
"request_id": request_id,
|
| 347 |
+
"error": "Image validation failed",
|
| 348 |
+
"message": str(exc),
|
| 349 |
+
})
|
|
|
|
|
|
|
| 350 |
except Exception as exc:
|
| 351 |
logger.exception("ANALYZE ERROR request_id=%s", request_id)
|
| 352 |
+
return _json({
|
| 353 |
+
"success": False,
|
| 354 |
+
"status": "error",
|
| 355 |
+
"request_id": request_id,
|
| 356 |
+
"error": "Internal server error",
|
| 357 |
+
"message": "حدث خطأ داخلي أثناء التحليل. يرجى المحاولة لاحقًا.",
|
| 358 |
+
})
|
|
|
|
|
|
|
| 359 |
|
| 360 |
|
| 361 |
@spaces.GPU(duration=30)
|
| 362 |
def gpu_health() -> str:
|
| 363 |
info = health_info()
|
| 364 |
+
info.update({
|
| 365 |
+
"server_version": SERVER_VERSION,
|
| 366 |
+
"transport": "Gradio Server + ZeroGPU",
|
| 367 |
+
"database": database.config_info(),
|
| 368 |
+
"runtime": _runtime_info(),
|
| 369 |
+
"api": {"analyze": True, "health": True, "classes": True},
|
| 370 |
+
})
|
|
|
|
|
|
|
| 371 |
return _json(info)
|
| 372 |
|
| 373 |
|
| 374 |
def health() -> str:
|
| 375 |
info = root_info()
|
| 376 |
+
info.update({
|
| 377 |
+
"server_version": SERVER_VERSION,
|
| 378 |
+
"transport": "Gradio Server + ZeroGPU",
|
| 379 |
+
"zero_gpu": True,
|
| 380 |
+
"database": database.config_info(),
|
| 381 |
+
"runtime": _runtime_info(),
|
| 382 |
+
})
|
|
|
|
|
|
|
| 383 |
return _json(info)
|
| 384 |
|
| 385 |
|
|
|
|
| 388 |
|
| 389 |
|
| 390 |
def db_health() -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 391 |
try:
|
| 392 |
result = database.health_check()
|
| 393 |
except Exception as exc:
|
|
|
|
| 401 |
return _json(result)
|
| 402 |
|
| 403 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
def _health_endpoint() -> str:
|
|
|
|
| 405 |
info = root_info()
|
| 406 |
+
info.update({
|
| 407 |
+
"server_version": SERVER_VERSION,
|
| 408 |
+
"transport": "Gradio Blocks + ZeroGPU",
|
| 409 |
+
"zero_gpu": True,
|
| 410 |
+
"database": database.config_info(),
|
| 411 |
+
"runtime": _runtime_info(),
|
| 412 |
+
"api": {
|
| 413 |
+
"analyze": True,
|
| 414 |
+
"health": True,
|
| 415 |
+
"gpu_health": True,
|
| 416 |
+
"classes": True,
|
| 417 |
+
"list_reports": True,
|
| 418 |
+
"get_report": True,
|
| 419 |
+
"review_report": True,
|
| 420 |
+
"update_report_content": True,
|
| 421 |
+
},
|
| 422 |
+
})
|
| 423 |
return _json(info)
|
| 424 |
|
| 425 |
|
| 426 |
def _classes_endpoint() -> str:
|
|
|
|
| 427 |
return _json(classes_info())
|
| 428 |
|
| 429 |
|
|
|
|
|
|
|
|
|
|
| 430 |
with gr.Blocks(
|
| 431 |
title="FeatureX Clinical AI — ZeroGPU",
|
| 432 |
analytics_enabled=False,
|
|
|
|
| 444 |
format="png",
|
| 445 |
height=360,
|
| 446 |
)
|
| 447 |
+
language = gr.Radio(["ar", "en"], value="ar", label="Report Language")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 448 |
|
| 449 |
with gr.Row():
|
| 450 |
patient_name = gr.Textbox(label="patient_name")
|
|
|
|
| 463 |
analyze_button = gr.Button("Analyze X-Ray", variant="primary")
|
| 464 |
analyze_output = gr.Textbox(label="JSON response", lines=20)
|
| 465 |
|
|
|
|
| 466 |
analyze_button.click(
|
| 467 |
analyze,
|
| 468 |
inputs=[
|
|
|
|
| 487 |
health_button = gr.Button("Health")
|
| 488 |
health_output = gr.Textbox(label="Health JSON")
|
| 489 |
health_button.click(
|
| 490 |
+
_health_endpoint, inputs=[], outputs=health_output, api_name="health"
|
|
|
|
|
|
|
|
|
|
| 491 |
)
|
| 492 |
|
| 493 |
classes_button = gr.Button("Classes")
|
| 494 |
classes_output = gr.Textbox(label="Classes JSON")
|
| 495 |
classes_button.click(
|
| 496 |
+
_classes_endpoint, inputs=[], outputs=classes_output, api_name="classes"
|
|
|
|
|
|
|
|
|
|
| 497 |
)
|
| 498 |
|
| 499 |
gpu_health_button = gr.Button("GPU Health")
|
| 500 |
gpu_health_output = gr.Textbox(label="GPU Health JSON")
|
| 501 |
gpu_health_button.click(
|
| 502 |
+
gpu_health, inputs=[], outputs=gpu_health_output, api_name="gpu_health"
|
|
|
|
|
|
|
|
|
|
| 503 |
)
|
| 504 |
|
| 505 |
db_health_button = gr.Button("Database / Storage Health")
|
| 506 |
db_health_output = gr.Textbox(label="Database / Storage Health JSON", lines=12)
|
| 507 |
db_health_button.click(
|
| 508 |
+
db_health, inputs=[], outputs=db_health_output, api_name="db_health"
|
|
|
|
|
|
|
|
|
|
| 509 |
)
|
| 510 |
|
| 511 |
+
# ---------- Protected report workflow APIs ----------
|
|
|
|
| 512 |
report_user_id = gr.Textbox(visible=False)
|
| 513 |
report_token = gr.Textbox(visible=False)
|
| 514 |
report_id = gr.Textbox(visible=False)
|
|
|
|
| 518 |
list_reports_button = gr.Button("List Reports API", visible=False)
|
| 519 |
list_reports_output = gr.Textbox(visible=False)
|
| 520 |
list_reports_button.click(
|
| 521 |
+
list_reports_api,
|
| 522 |
+
inputs=[report_user_id, report_token],
|
| 523 |
+
outputs=list_reports_output,
|
| 524 |
+
api_name="list_reports",
|
| 525 |
)
|
| 526 |
|
| 527 |
get_report_button = gr.Button("Get Report API", visible=False)
|
| 528 |
get_report_output = gr.Textbox(visible=False)
|
| 529 |
get_report_button.click(
|
| 530 |
+
get_report_api,
|
| 531 |
+
inputs=[report_id, report_token],
|
| 532 |
+
outputs=get_report_output,
|
| 533 |
+
api_name="get_report",
|
| 534 |
)
|
| 535 |
|
| 536 |
review_report_button = gr.Button("Review Report API", visible=False)
|
| 537 |
review_report_output = gr.Textbox(visible=False)
|
| 538 |
review_report_button.click(
|
| 539 |
+
review_report_api,
|
| 540 |
+
inputs=[report_id, review_action, review_notes, report_token],
|
| 541 |
+
outputs=review_report_output,
|
| 542 |
+
api_name="review_report",
|
| 543 |
+
)
|
| 544 |
+
|
| 545 |
+
# ---------- Report content editing (before approval) ----------
|
| 546 |
+
edit_id = gr.Textbox(visible=False)
|
| 547 |
+
edit_findings = gr.Textbox(visible=False)
|
| 548 |
+
edit_impression = gr.Textbox(visible=False)
|
| 549 |
+
edit_recs = gr.Textbox(visible=False)
|
| 550 |
+
edit_token = gr.Textbox(visible=False)
|
| 551 |
+
edit_output = gr.Textbox(visible=False)
|
| 552 |
+
edit_button = gr.Button("Update Report Content API", visible=False)
|
| 553 |
+
edit_button.click(
|
| 554 |
+
update_report_content_api,
|
| 555 |
+
inputs=[edit_id, edit_findings, edit_impression, edit_recs, edit_token],
|
| 556 |
+
outputs=edit_output,
|
| 557 |
+
api_name="update_report_content",
|
| 558 |
)
|
| 559 |
|
| 560 |
+
|
| 561 |
if __name__ == "__main__":
|
| 562 |
demo.launch(
|
| 563 |
show_error=True,
|
| 564 |
max_file_size="15mb",
|
| 565 |
ssr_mode=False,
|
| 566 |
+
)
|
report_workflow.py
CHANGED
|
@@ -1,5 +1,4 @@
|
|
| 1 |
"""Firebase-authenticated report workflow for FeatureX Gradio APIs."""
|
| 2 |
-
|
| 3 |
import json
|
| 4 |
import logging
|
| 5 |
import os
|
|
@@ -8,556 +7,212 @@ from functools import lru_cache
|
|
| 8 |
|
| 9 |
import database
|
| 10 |
|
| 11 |
-
|
| 12 |
logger = logging.getLogger("featurex-report-workflow")
|
| 13 |
|
| 14 |
|
| 15 |
-
# ============================================================
|
| 16 |
-
# Firebase Admin initialization
|
| 17 |
-
# ============================================================
|
| 18 |
-
|
| 19 |
@lru_cache(maxsize=1)
|
| 20 |
def _firebase_admin():
|
| 21 |
-
"""Initialize Firebase Admin
|
| 22 |
import firebase_admin
|
| 23 |
from firebase_admin import credentials
|
| 24 |
-
|
| 25 |
if firebase_admin._apps:
|
| 26 |
return firebase_admin
|
| 27 |
-
|
| 28 |
-
raw = os.environ.get(
|
| 29 |
-
"FIREBASE_SERVICE_ACCOUNT_JSON", ""
|
| 30 |
-
).strip()
|
| 31 |
-
|
| 32 |
if not raw:
|
| 33 |
-
raise RuntimeError(
|
| 34 |
-
"FIREBASE_SERVICE_ACCOUNT_JSON is not configured on the server."
|
| 35 |
-
)
|
| 36 |
-
|
| 37 |
try:
|
| 38 |
service_account = json.loads(raw)
|
| 39 |
except json.JSONDecodeError as exc:
|
| 40 |
-
raise RuntimeError(
|
| 41 |
-
|
| 42 |
-
) from exc
|
| 43 |
-
|
| 44 |
-
firebase_admin.initialize_app(
|
| 45 |
-
credentials.Certificate(service_account)
|
| 46 |
-
)
|
| 47 |
-
|
| 48 |
return firebase_admin
|
| 49 |
|
| 50 |
|
| 51 |
-
# ============================================================
|
| 52 |
-
# Authentication and role normalization
|
| 53 |
-
# ============================================================
|
| 54 |
-
|
| 55 |
def _identity(token: str, claimed_user_id: str = "") -> dict:
|
| 56 |
-
"""
|
| 57 |
-
Verify the Firebase ID token and normalize the user's role.
|
| 58 |
-
|
| 59 |
-
Legacy roles such as clinical and clinician are mapped to doctor.
|
| 60 |
-
The Firebase UID is the authoritative user identifier.
|
| 61 |
-
"""
|
| 62 |
-
|
| 63 |
token = (token or "").strip()
|
| 64 |
-
|
| 65 |
if not token:
|
| 66 |
-
raise PermissionError(
|
| 67 |
-
"unauthorized: Firebase token is required"
|
| 68 |
-
)
|
| 69 |
-
|
| 70 |
admin = _firebase_admin()
|
| 71 |
-
|
| 72 |
from firebase_admin import auth
|
| 73 |
-
|
| 74 |
-
decoded = auth.verify_id_token(
|
| 75 |
-
token,
|
| 76 |
-
check_revoked=True,
|
| 77 |
-
)
|
| 78 |
-
|
| 79 |
uid = str(decoded.get("uid") or "").strip()
|
| 80 |
-
|
| 81 |
if not uid:
|
| 82 |
-
raise PermissionError(
|
| 83 |
-
|
| 84 |
-
)
|
| 85 |
-
|
| 86 |
-
claimed_user_id = (claimed_user_id or "").strip()
|
| 87 |
-
|
| 88 |
-
if claimed_user_id and claimed_user_id != uid:
|
| 89 |
-
raise PermissionError(
|
| 90 |
-
"forbidden: user_id does not match Firebase token"
|
| 91 |
-
)
|
| 92 |
-
|
| 93 |
-
raw_role = str(
|
| 94 |
-
decoded.get("role")
|
| 95 |
-
or decoded.get("user_role")
|
| 96 |
-
or "patient"
|
| 97 |
-
).strip().lower()
|
| 98 |
-
|
| 99 |
role_aliases = {
|
| 100 |
-
|
| 101 |
-
"
|
| 102 |
-
"
|
| 103 |
-
"
|
| 104 |
-
"specialist": "doctor",
|
| 105 |
-
"physician": "doctor",
|
| 106 |
-
"radiologist": "doctor",
|
| 107 |
-
|
| 108 |
-
# Department management
|
| 109 |
"department head": "department_head",
|
| 110 |
"head_of_department": "department_head",
|
| 111 |
-
"department_head": "department_head",
|
| 112 |
-
"department_manager": "department_manager",
|
| 113 |
-
|
| 114 |
-
# Other roles
|
| 115 |
"supervisor": "supervisor",
|
| 116 |
"admin": "admin",
|
| 117 |
"administrator": "admin",
|
| 118 |
"patient": "patient",
|
| 119 |
"user": "patient",
|
|
|
|
|
|
|
| 120 |
}
|
| 121 |
-
|
| 122 |
-
role = role_aliases.get(raw_role, raw_role)
|
| 123 |
-
|
| 124 |
return {
|
| 125 |
"uid": uid,
|
| 126 |
"role": role,
|
| 127 |
-
"department": str(
|
| 128 |
-
|
| 129 |
-
).strip(),
|
| 130 |
-
"supervisor_id": str(
|
| 131 |
-
decoded.get("supervisor_id") or ""
|
| 132 |
-
).strip(),
|
| 133 |
}
|
| 134 |
|
| 135 |
|
| 136 |
-
# ============================================================
|
| 137 |
-
# JSON response helper
|
| 138 |
-
# ============================================================
|
| 139 |
-
|
| 140 |
def _ok(payload):
|
| 141 |
-
return json.dumps(
|
| 142 |
-
payload,
|
| 143 |
-
ensure_ascii=False,
|
| 144 |
-
separators=(",", ":"),
|
| 145 |
-
)
|
| 146 |
-
|
| 147 |
|
| 148 |
-
# ============================================================
|
| 149 |
-
# Database helpers
|
| 150 |
-
# ============================================================
|
| 151 |
|
| 152 |
def _row(analysis_id):
|
| 153 |
-
"""Retrieve one analysis record by its ID."""
|
| 154 |
-
|
| 155 |
-
analysis_id = str(analysis_id or "").strip()
|
| 156 |
-
|
| 157 |
-
if not analysis_id:
|
| 158 |
-
return None
|
| 159 |
-
|
| 160 |
rows = database._json_request(
|
| 161 |
-
"GET",
|
| 162 |
-
"analyses",
|
| 163 |
-
params={
|
| 164 |
-
"id": f"eq.{analysis_id}",
|
| 165 |
-
"limit": "1",
|
| 166 |
-
},
|
| 167 |
)
|
|
|
|
| 168 |
|
| 169 |
-
if isinstance(rows, list) and rows:
|
| 170 |
-
row = rows[0]
|
| 171 |
-
|
| 172 |
-
if isinstance(row, dict):
|
| 173 |
-
return row
|
| 174 |
-
|
| 175 |
-
return None
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
# ============================================================
|
| 179 |
-
# Report access authorization
|
| 180 |
-
# ============================================================
|
| 181 |
|
| 182 |
def _can_read(identity, row):
|
| 183 |
-
"""
|
| 184 |
-
Check whether the authenticated user can access a report.
|
| 185 |
-
|
| 186 |
-
Patient access requires ownership, approval, and visibility.
|
| 187 |
-
Doctor access requires ownership or explicit assignment.
|
| 188 |
-
"""
|
| 189 |
-
|
| 190 |
-
role = identity["role"]
|
| 191 |
-
uid = identity["uid"]
|
| 192 |
-
|
| 193 |
if role == "admin":
|
| 194 |
return True
|
| 195 |
-
|
| 196 |
if role == "patient":
|
| 197 |
return (
|
| 198 |
str(row.get("user_id") or "") == uid
|
| 199 |
and row.get("visible_to_patient") is True
|
| 200 |
and row.get("workflow_status") == "approved"
|
| 201 |
)
|
| 202 |
-
|
| 203 |
-
if role == "doctor":
|
| 204 |
return (
|
| 205 |
str(row.get("user_id") or "") == uid
|
| 206 |
or str(row.get("assigned_to") or "") == uid
|
| 207 |
)
|
| 208 |
-
|
| 209 |
-
if role in {
|
| 210 |
-
"supervisor",
|
| 211 |
-
"department_head",
|
| 212 |
-
"department_manager",
|
| 213 |
-
}:
|
| 214 |
same_department = (
|
| 215 |
bool(identity.get("department"))
|
| 216 |
-
and str(row.get("department") or "")
|
| 217 |
-
== identity["department"]
|
| 218 |
-
)
|
| 219 |
-
|
| 220 |
-
assigned = (
|
| 221 |
-
str(row.get("assigned_to") or "") == uid
|
| 222 |
)
|
| 223 |
-
|
| 224 |
-
supervised = (
|
| 225 |
-
str(row.get("supervisor_id") or "") == uid
|
| 226 |
-
)
|
| 227 |
-
|
| 228 |
return same_department or assigned or supervised
|
| 229 |
-
|
| 230 |
return False
|
| 231 |
|
| 232 |
|
| 233 |
-
#
|
| 234 |
-
#
|
| 235 |
-
#
|
| 236 |
-
|
| 237 |
-
def list_reports_api(
|
| 238 |
-
claimed_user_id: str,
|
| 239 |
-
firebase_token: str,
|
| 240 |
-
) -> str:
|
| 241 |
-
"""
|
| 242 |
-
Return reports visible to the authenticated user.
|
| 243 |
-
|
| 244 |
-
Patient responses contain only basic request metadata.
|
| 245 |
-
Clinical report content is not returned to patients before approval.
|
| 246 |
-
"""
|
| 247 |
-
|
| 248 |
try:
|
| 249 |
-
identity = _identity(
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
)
|
| 253 |
-
|
| 254 |
-
role = identity["role"]
|
| 255 |
-
uid = identity["uid"]
|
| 256 |
-
|
| 257 |
-
params = {
|
| 258 |
-
"order": "created_at.desc",
|
| 259 |
-
"limit": "200",
|
| 260 |
-
}
|
| 261 |
-
|
| 262 |
-
# ----------------------------------------------------
|
| 263 |
-
# Patient: retrieve only the patient's own requests.
|
| 264 |
-
# Sensitive report fields are removed below.
|
| 265 |
-
# ----------------------------------------------------
|
| 266 |
-
|
| 267 |
if role == "patient":
|
| 268 |
-
params
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
# ----------------------------------------------------
|
| 273 |
-
|
| 274 |
-
elif role == "doctor":
|
| 275 |
-
params["or"] = (
|
| 276 |
-
f"(user_id.eq.{uid},"
|
| 277 |
-
f"assigned_to.eq.{uid})"
|
| 278 |
-
)
|
| 279 |
-
|
| 280 |
-
# ----------------------------------------------------
|
| 281 |
-
# Supervisor and department management.
|
| 282 |
-
# ----------------------------------------------------
|
| 283 |
-
|
| 284 |
-
elif role in {
|
| 285 |
-
"supervisor",
|
| 286 |
-
"department_head",
|
| 287 |
-
"department_manager",
|
| 288 |
-
}:
|
| 289 |
department = identity.get("department", "")
|
| 290 |
-
|
| 291 |
if department:
|
| 292 |
params["or"] = (
|
| 293 |
f"(department.eq.{department},"
|
| 294 |
-
f"assigned_to.eq.{uid},"
|
| 295 |
-
f"supervisor_id.eq.{uid})"
|
| 296 |
)
|
| 297 |
else:
|
| 298 |
-
params["or"] = (
|
| 299 |
-
f"(assigned_to.eq.{uid},"
|
| 300 |
-
f"supervisor_id.eq.{uid})"
|
| 301 |
-
)
|
| 302 |
-
|
| 303 |
-
# ----------------------------------------------------
|
| 304 |
-
# Administrator: no additional user filter.
|
| 305 |
-
# ----------------------------------------------------
|
| 306 |
-
|
| 307 |
elif role != "admin":
|
| 308 |
-
return _ok({
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
"Role is not permitted to list reports."
|
| 312 |
-
),
|
| 313 |
-
})
|
| 314 |
-
|
| 315 |
-
rows = database._json_request(
|
| 316 |
-
"GET",
|
| 317 |
-
"analyses",
|
| 318 |
-
params=params,
|
| 319 |
-
)
|
| 320 |
-
|
| 321 |
-
if not isinstance(rows, list):
|
| 322 |
-
rows = []
|
| 323 |
-
|
| 324 |
-
# ----------------------------------------------------
|
| 325 |
-
# Minimize patient-facing report data.
|
| 326 |
-
# ----------------------------------------------------
|
| 327 |
-
|
| 328 |
if role == "patient":
|
| 329 |
safe_rows = []
|
| 330 |
-
|
| 331 |
for row in rows:
|
| 332 |
-
if not isinstance(row, dict):
|
| 333 |
-
continue
|
| 334 |
-
|
| 335 |
-
if str(row.get("user_id") or "") != uid:
|
| 336 |
continue
|
| 337 |
-
|
| 338 |
safe_rows.append({
|
| 339 |
"id": row.get("id"),
|
| 340 |
"created_at": row.get("created_at"),
|
| 341 |
"updated_at": row.get("updated_at"),
|
| 342 |
"status": row.get("status"),
|
| 343 |
-
"workflow_status": (
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
or "pending_clinician_review"
|
| 347 |
-
),
|
| 348 |
-
"visible_to_patient": bool(
|
| 349 |
-
row.get("visible_to_patient")
|
| 350 |
-
),
|
| 351 |
-
"approval_required": row.get(
|
| 352 |
-
"approval_required",
|
| 353 |
-
True,
|
| 354 |
-
),
|
| 355 |
})
|
| 356 |
-
|
| 357 |
rows = safe_rows
|
| 358 |
-
|
| 359 |
-
return _ok({
|
| 360 |
-
"status": "success",
|
| 361 |
-
"analyses": rows,
|
| 362 |
-
"count": len(rows),
|
| 363 |
-
})
|
| 364 |
-
|
| 365 |
except PermissionError as exc:
|
| 366 |
-
message = str(exc)
|
| 367 |
-
|
| 368 |
return _ok({
|
| 369 |
-
"status": (
|
| 370 |
-
|
| 371 |
-
if "unauthorized" in message
|
| 372 |
-
else "forbidden"
|
| 373 |
-
),
|
| 374 |
-
"message": message,
|
| 375 |
})
|
| 376 |
-
|
| 377 |
except Exception:
|
| 378 |
logger.exception("list_reports failed")
|
|
|
|
| 379 |
|
| 380 |
-
return _ok({
|
| 381 |
-
"status": "error",
|
| 382 |
-
"message": "Unable to list reports.",
|
| 383 |
-
})
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
# ============================================================
|
| 387 |
-
# Get report API
|
| 388 |
-
# ============================================================
|
| 389 |
-
|
| 390 |
-
def get_report_api(
|
| 391 |
-
analysis_id: str,
|
| 392 |
-
firebase_token: str,
|
| 393 |
-
) -> str:
|
| 394 |
-
"""Retrieve a report after checking access permissions."""
|
| 395 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 396 |
try:
|
| 397 |
identity = _identity(firebase_token)
|
| 398 |
-
|
| 399 |
-
row = _row(
|
| 400 |
-
(analysis_id or "").strip()
|
| 401 |
-
)
|
| 402 |
-
|
| 403 |
if not row or not _can_read(identity, row):
|
| 404 |
return _ok({
|
| 405 |
"status": "not_found",
|
| 406 |
-
"message":
|
| 407 |
-
"Report not found or access denied."
|
| 408 |
-
),
|
| 409 |
})
|
| 410 |
-
|
| 411 |
-
return _ok({
|
| 412 |
-
"status": "success",
|
| 413 |
-
"analysis": row,
|
| 414 |
-
})
|
| 415 |
-
|
| 416 |
except PermissionError as exc:
|
| 417 |
-
message = str(exc)
|
| 418 |
-
|
| 419 |
return _ok({
|
| 420 |
-
"status": (
|
| 421 |
-
|
| 422 |
-
if "unauthorized" in message
|
| 423 |
-
else "forbidden"
|
| 424 |
-
),
|
| 425 |
-
"message": message,
|
| 426 |
})
|
| 427 |
-
|
| 428 |
except Exception:
|
| 429 |
logger.exception("get_report failed")
|
|
|
|
| 430 |
|
| 431 |
-
return _ok({
|
| 432 |
-
"status": "error",
|
| 433 |
-
"message": "Unable to retrieve report.",
|
| 434 |
-
})
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
# ============================================================
|
| 438 |
-
# Review report API
|
| 439 |
-
# ============================================================
|
| 440 |
-
|
| 441 |
-
def review_report_api(
|
| 442 |
-
analysis_id: str,
|
| 443 |
-
action: str,
|
| 444 |
-
notes: str,
|
| 445 |
-
firebase_token: str,
|
| 446 |
-
) -> str:
|
| 447 |
-
"""
|
| 448 |
-
Review a report.
|
| 449 |
-
|
| 450 |
-
Doctors can move eligible reports to supervisor review.
|
| 451 |
-
Supervisor, department management, and admin roles can
|
| 452 |
-
finalize approval according to the workflow rules below.
|
| 453 |
-
"""
|
| 454 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 455 |
try:
|
| 456 |
identity = _identity(firebase_token)
|
| 457 |
-
|
| 458 |
role = identity["role"]
|
|
|
|
|
|
|
| 459 |
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
"
|
| 465 |
-
"department_manager",
|
| 466 |
-
"admin",
|
| 467 |
-
}
|
| 468 |
-
|
| 469 |
-
if role not in permitted_roles:
|
| 470 |
-
return _ok({
|
| 471 |
-
"status": "forbidden",
|
| 472 |
-
"message": (
|
| 473 |
-
"Doctor review role required."
|
| 474 |
-
),
|
| 475 |
-
})
|
| 476 |
-
|
| 477 |
-
analysis_id = (
|
| 478 |
-
analysis_id or ""
|
| 479 |
-
).strip()
|
| 480 |
-
|
| 481 |
-
action = (
|
| 482 |
-
action or ""
|
| 483 |
-
).strip().lower()
|
| 484 |
-
|
| 485 |
-
notes = (
|
| 486 |
-
notes or ""
|
| 487 |
-
).strip()[:4000]
|
| 488 |
-
|
| 489 |
-
if not analysis_id:
|
| 490 |
-
return _ok({
|
| 491 |
-
"status": "invalid_request",
|
| 492 |
-
"message": "Analysis ID is required.",
|
| 493 |
-
})
|
| 494 |
-
|
| 495 |
-
if action not in {
|
| 496 |
-
"approve",
|
| 497 |
-
"request_revision",
|
| 498 |
-
}:
|
| 499 |
-
return _ok({
|
| 500 |
-
"status": "invalid_action",
|
| 501 |
-
"message": "Unsupported review action.",
|
| 502 |
-
})
|
| 503 |
|
| 504 |
row = _row(analysis_id)
|
| 505 |
-
|
| 506 |
if not row:
|
| 507 |
-
return _ok({
|
| 508 |
-
"status": "not_found",
|
| 509 |
-
"message": "Report not found.",
|
| 510 |
-
})
|
| 511 |
-
|
| 512 |
-
# ----------------------------------------------------
|
| 513 |
-
# Authorization
|
| 514 |
-
# ----------------------------------------------------
|
| 515 |
-
|
| 516 |
if role != "admin" and not _can_read(identity, row):
|
| 517 |
-
return _ok({
|
| 518 |
-
"status": "forbidden",
|
| 519 |
-
"message": (
|
| 520 |
-
"Report is outside your assigned scope."
|
| 521 |
-
),
|
| 522 |
-
})
|
| 523 |
|
| 524 |
current_status = str(
|
| 525 |
-
row.get("workflow_status")
|
| 526 |
-
or row.get("status")
|
| 527 |
-
or "pending_clinician_review"
|
| 528 |
)
|
| 529 |
|
| 530 |
-
# ----------------------------------------------------
|
| 531 |
-
# Validate approval transitions.
|
| 532 |
-
# ----------------------------------------------------
|
| 533 |
-
|
| 534 |
if action == "approve":
|
| 535 |
-
|
| 536 |
-
if role == "doctor" and current_status not in {
|
| 537 |
-
"pending_clinician_review",
|
| 538 |
-
"needs_revision",
|
| 539 |
-
}:
|
| 540 |
return _ok({
|
| 541 |
"status": "invalid_transition",
|
| 542 |
-
"message":
|
| 543 |
-
"Doctors can review only reports awaiting "
|
| 544 |
-
"doctor review or revision."
|
| 545 |
-
),
|
| 546 |
})
|
| 547 |
-
|
| 548 |
-
if role in {
|
| 549 |
-
"supervisor",
|
| 550 |
-
"department_head",
|
| 551 |
-
"department_manager",
|
| 552 |
-
} and current_status != "pending_supervisor_review":
|
| 553 |
return _ok({
|
| 554 |
"status": "invalid_transition",
|
| 555 |
-
"message":
|
| 556 |
-
"Supervisor approval requires "
|
| 557 |
-
"pending_supervisor_review status."
|
| 558 |
-
),
|
| 559 |
})
|
| 560 |
-
|
| 561 |
if role == "admin" and current_status not in {
|
| 562 |
"pending_clinician_review",
|
| 563 |
"needs_revision",
|
|
@@ -566,18 +221,10 @@ def review_report_api(
|
|
| 566 |
}:
|
| 567 |
return _ok({
|
| 568 |
"status": "invalid_transition",
|
| 569 |
-
"message":
|
| 570 |
-
"Report cannot be approved "
|
| 571 |
-
"from its current status."
|
| 572 |
-
),
|
| 573 |
})
|
| 574 |
|
| 575 |
-
# ----------------------------------------------------
|
| 576 |
-
# Prepare update
|
| 577 |
-
# ----------------------------------------------------
|
| 578 |
-
|
| 579 |
now = datetime.now(timezone.utc).isoformat()
|
| 580 |
-
|
| 581 |
patch = {
|
| 582 |
"reviewed_by": identity["uid"],
|
| 583 |
"review_notes": notes,
|
|
@@ -585,10 +232,6 @@ def review_report_api(
|
|
| 585 |
"updated_at": now,
|
| 586 |
}
|
| 587 |
|
| 588 |
-
# ----------------------------------------------------
|
| 589 |
-
# Request revisions
|
| 590 |
-
# ----------------------------------------------------
|
| 591 |
-
|
| 592 |
if action == "request_revision":
|
| 593 |
patch.update({
|
| 594 |
"workflow_status": "needs_revision",
|
|
@@ -596,25 +239,14 @@ def review_report_api(
|
|
| 596 |
"visible_to_patient": False,
|
| 597 |
"approval_required": True,
|
| 598 |
})
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
# Doctor approval is not final patient approval.
|
| 602 |
-
# The report proceeds to supervisor review.
|
| 603 |
-
# ----------------------------------------------------
|
| 604 |
-
|
| 605 |
-
elif role == "doctor":
|
| 606 |
patch.update({
|
| 607 |
"workflow_status": "pending_supervisor_review",
|
| 608 |
"status": "pending_supervisor_review",
|
| 609 |
"visible_to_patient": False,
|
| 610 |
"approval_required": True,
|
| 611 |
})
|
| 612 |
-
|
| 613 |
-
# ----------------------------------------------------
|
| 614 |
-
# Final approval by supervisor, department management,
|
| 615 |
-
# or admin.
|
| 616 |
-
# ----------------------------------------------------
|
| 617 |
-
|
| 618 |
else:
|
| 619 |
patch.update({
|
| 620 |
"workflow_status": "approved",
|
|
@@ -625,52 +257,124 @@ def review_report_api(
|
|
| 625 |
"approved_at": now,
|
| 626 |
})
|
| 627 |
|
| 628 |
-
# ----------------------------------------------------
|
| 629 |
-
# Persist update
|
| 630 |
-
# ----------------------------------------------------
|
| 631 |
-
|
| 632 |
updated = database._json_request(
|
| 633 |
-
"PATCH",
|
| 634 |
-
"analyses",
|
| 635 |
-
patch,
|
| 636 |
-
params={
|
| 637 |
-
"id": f"eq.{analysis_id}",
|
| 638 |
-
},
|
| 639 |
)
|
| 640 |
-
|
| 641 |
-
updated_row = (
|
| 642 |
updated[0]
|
| 643 |
if isinstance(updated, list) and updated
|
| 644 |
-
else {
|
| 645 |
-
"id": analysis_id,
|
| 646 |
-
**patch,
|
| 647 |
-
}
|
| 648 |
)
|
| 649 |
|
|
|
|
|
|
|
| 650 |
return _ok({
|
| 651 |
"status": "success",
|
| 652 |
"message": "Review status updated.",
|
| 653 |
-
"
|
|
|
|
|
|
|
| 654 |
})
|
| 655 |
-
|
| 656 |
except PermissionError as exc:
|
| 657 |
-
message = str(exc)
|
| 658 |
-
|
| 659 |
return _ok({
|
| 660 |
-
"status": (
|
| 661 |
-
|
| 662 |
-
if "unauthorized" in message
|
| 663 |
-
else "forbidden"
|
| 664 |
-
),
|
| 665 |
-
"message": message,
|
| 666 |
})
|
| 667 |
-
|
| 668 |
except Exception:
|
| 669 |
logger.exception("review_report failed")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 670 |
|
| 671 |
return _ok({
|
| 672 |
-
"status": "
|
| 673 |
-
"message":
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""Firebase-authenticated report workflow for FeatureX Gradio APIs."""
|
|
|
|
| 2 |
import json
|
| 3 |
import logging
|
| 4 |
import os
|
|
|
|
| 7 |
|
| 8 |
import database
|
| 9 |
|
|
|
|
| 10 |
logger = logging.getLogger("featurex-report-workflow")
|
| 11 |
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
@lru_cache(maxsize=1)
|
| 14 |
def _firebase_admin():
|
| 15 |
+
"""Initialize Firebase Admin from a Hugging Face secret, never from client data."""
|
| 16 |
import firebase_admin
|
| 17 |
from firebase_admin import credentials
|
|
|
|
| 18 |
if firebase_admin._apps:
|
| 19 |
return firebase_admin
|
| 20 |
+
raw = os.environ.get("FIREBASE_SERVICE_ACCOUNT_JSON", "").strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
if not raw:
|
| 22 |
+
raise RuntimeError("FIREBASE_SERVICE_ACCOUNT_JSON is not configured on the server.")
|
|
|
|
|
|
|
|
|
|
| 23 |
try:
|
| 24 |
service_account = json.loads(raw)
|
| 25 |
except json.JSONDecodeError as exc:
|
| 26 |
+
raise RuntimeError("FIREBASE_SERVICE_ACCOUNT_JSON is invalid JSON.") from exc
|
| 27 |
+
firebase_admin.initialize_app(credentials.Certificate(service_account))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
return firebase_admin
|
| 29 |
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
def _identity(token: str, claimed_user_id: str = "") -> dict:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
token = (token or "").strip()
|
|
|
|
| 33 |
if not token:
|
| 34 |
+
raise PermissionError("unauthorized: Firebase token is required")
|
|
|
|
|
|
|
|
|
|
| 35 |
admin = _firebase_admin()
|
|
|
|
| 36 |
from firebase_admin import auth
|
| 37 |
+
decoded = auth.verify_id_token(token, check_revoked=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
uid = str(decoded.get("uid") or "").strip()
|
|
|
|
| 39 |
if not uid:
|
| 40 |
+
raise PermissionError("unauthorized: token has no uid")
|
| 41 |
+
if claimed_user_id and claimed_user_id.strip() != uid:
|
| 42 |
+
raise PermissionError("forbidden: user_id does not match Firebase token")
|
| 43 |
+
role = str(decoded.get("role") or decoded.get("user_role") or "patient").strip().lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
role_aliases = {
|
| 45 |
+
"doctor": "clinician",
|
| 46 |
+
"specialist": "clinician",
|
| 47 |
+
"physician": "clinician",
|
| 48 |
+
"radiologist": "clinician",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
"department head": "department_head",
|
| 50 |
"head_of_department": "department_head",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
"supervisor": "supervisor",
|
| 52 |
"admin": "admin",
|
| 53 |
"administrator": "admin",
|
| 54 |
"patient": "patient",
|
| 55 |
"user": "patient",
|
| 56 |
+
"clinician": "clinician",
|
| 57 |
+
"department_head": "department_head",
|
| 58 |
}
|
| 59 |
+
role = role_aliases.get(role, role)
|
|
|
|
|
|
|
| 60 |
return {
|
| 61 |
"uid": uid,
|
| 62 |
"role": role,
|
| 63 |
+
"department": str(decoded.get("department") or "").strip(),
|
| 64 |
+
"supervisor_id": str(decoded.get("supervisor_id") or "").strip(),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
}
|
| 66 |
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
def _ok(payload):
|
| 69 |
+
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
def _row(analysis_id):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
rows = database._json_request(
|
| 74 |
+
"GET", "analyses", params={"id": f"eq.{analysis_id}", "limit": "1"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
)
|
| 76 |
+
return rows[0] if isinstance(rows, list) and rows else None
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
def _can_read(identity, row):
|
| 80 |
+
role, uid = identity["role"], identity["uid"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
if role == "admin":
|
| 82 |
return True
|
|
|
|
| 83 |
if role == "patient":
|
| 84 |
return (
|
| 85 |
str(row.get("user_id") or "") == uid
|
| 86 |
and row.get("visible_to_patient") is True
|
| 87 |
and row.get("workflow_status") == "approved"
|
| 88 |
)
|
| 89 |
+
if role == "clinician":
|
|
|
|
| 90 |
return (
|
| 91 |
str(row.get("user_id") or "") == uid
|
| 92 |
or str(row.get("assigned_to") or "") == uid
|
| 93 |
)
|
| 94 |
+
if role in {"supervisor", "department_head"}:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
same_department = (
|
| 96 |
bool(identity.get("department"))
|
| 97 |
+
and str(row.get("department") or "") == identity["department"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
)
|
| 99 |
+
assigned = str(row.get("assigned_to") or "") == uid
|
| 100 |
+
supervised = str(row.get("supervisor_id") or "") == uid
|
|
|
|
|
|
|
|
|
|
| 101 |
return same_department or assigned or supervised
|
|
|
|
| 102 |
return False
|
| 103 |
|
| 104 |
|
| 105 |
+
# ----------------------------------------------------------------
|
| 106 |
+
# LIST
|
| 107 |
+
# ----------------------------------------------------------------
|
| 108 |
+
def list_reports_api(claimed_user_id: str, firebase_token: str) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
try:
|
| 110 |
+
identity = _identity(firebase_token, claimed_user_id)
|
| 111 |
+
role, uid = identity["role"], identity["uid"]
|
| 112 |
+
params = {"order": "created_at.desc", "limit": "200"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
if role == "patient":
|
| 114 |
+
params.update({"user_id": f"eq.{uid}"})
|
| 115 |
+
elif role == "clinician":
|
| 116 |
+
params["or"] = f"(user_id.eq.{uid},assigned_to.eq.{uid})"
|
| 117 |
+
elif role in {"supervisor", "department_head"}:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
department = identity.get("department", "")
|
|
|
|
| 119 |
if department:
|
| 120 |
params["or"] = (
|
| 121 |
f"(department.eq.{department},"
|
| 122 |
+
f"assigned_to.eq.{uid},supervisor_id.eq.{uid})"
|
|
|
|
| 123 |
)
|
| 124 |
else:
|
| 125 |
+
params["or"] = f"(assigned_to.eq.{uid},supervisor_id.eq.{uid})"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
elif role != "admin":
|
| 127 |
+
return _ok({"status": "forbidden", "message": "Role is not permitted to list reports."})
|
| 128 |
+
rows = database._json_request("GET", "analyses", params=params)
|
| 129 |
+
rows = rows if isinstance(rows, list) else []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
if role == "patient":
|
| 131 |
safe_rows = []
|
|
|
|
| 132 |
for row in rows:
|
| 133 |
+
if not isinstance(row, dict) or str(row.get("user_id") or "") != uid:
|
|
|
|
|
|
|
|
|
|
| 134 |
continue
|
|
|
|
| 135 |
safe_rows.append({
|
| 136 |
"id": row.get("id"),
|
| 137 |
"created_at": row.get("created_at"),
|
| 138 |
"updated_at": row.get("updated_at"),
|
| 139 |
"status": row.get("status"),
|
| 140 |
+
"workflow_status": row.get("workflow_status") or row.get("status") or "pending_clinician_review",
|
| 141 |
+
"visible_to_patient": bool(row.get("visible_to_patient")),
|
| 142 |
+
"approval_required": row.get("approval_required", True),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
})
|
|
|
|
| 144 |
rows = safe_rows
|
| 145 |
+
return _ok({"status": "success", "analyses": rows, "count": len(rows)})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
except PermissionError as exc:
|
|
|
|
|
|
|
| 147 |
return _ok({
|
| 148 |
+
"status": "unauthorized" if "unauthorized" in str(exc) else "forbidden",
|
| 149 |
+
"message": str(exc),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
})
|
|
|
|
| 151 |
except Exception:
|
| 152 |
logger.exception("list_reports failed")
|
| 153 |
+
return _ok({"status": "error", "message": "Unable to list reports."})
|
| 154 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
|
| 156 |
+
# ----------------------------------------------------------------
|
| 157 |
+
# GET
|
| 158 |
+
# ----------------------------------------------------------------
|
| 159 |
+
def get_report_api(analysis_id: str, firebase_token: str) -> str:
|
| 160 |
try:
|
| 161 |
identity = _identity(firebase_token)
|
| 162 |
+
row = _row((analysis_id or "").strip())
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
if not row or not _can_read(identity, row):
|
| 164 |
return _ok({
|
| 165 |
"status": "not_found",
|
| 166 |
+
"message": "Report not found or access denied.",
|
|
|
|
|
|
|
| 167 |
})
|
| 168 |
+
return _ok({"status": "success", "analysis": row})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
except PermissionError as exc:
|
|
|
|
|
|
|
| 170 |
return _ok({
|
| 171 |
+
"status": "unauthorized" if "unauthorized" in str(exc) else "forbidden",
|
| 172 |
+
"message": str(exc),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
})
|
|
|
|
| 174 |
except Exception:
|
| 175 |
logger.exception("get_report failed")
|
| 176 |
+
return _ok({"status": "error", "message": "Unable to retrieve report."})
|
| 177 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
|
| 179 |
+
# ----------------------------------------------------------------
|
| 180 |
+
# REVIEW (approve / request_revision)
|
| 181 |
+
# ----------------------------------------------------------------
|
| 182 |
+
def review_report_api(analysis_id: str, action: str, notes: str, firebase_token: str) -> str:
|
| 183 |
try:
|
| 184 |
identity = _identity(firebase_token)
|
|
|
|
| 185 |
role = identity["role"]
|
| 186 |
+
if role not in {"clinician", "supervisor", "department_head", "admin"}:
|
| 187 |
+
return _ok({"status": "forbidden", "message": "Clinician review role required."})
|
| 188 |
|
| 189 |
+
analysis_id = (analysis_id or "").strip()
|
| 190 |
+
action = (action or "").strip().lower()
|
| 191 |
+
notes = (notes or "").strip()[:4000]
|
| 192 |
+
if action not in {"approve", "request_revision"}:
|
| 193 |
+
return _ok({"status": "invalid_action", "message": "Unsupported review action."})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
|
| 195 |
row = _row(analysis_id)
|
|
|
|
| 196 |
if not row:
|
| 197 |
+
return _ok({"status": "not_found", "message": "Report not found."})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
if role != "admin" and not _can_read(identity, row):
|
| 199 |
+
return _ok({"status": "forbidden", "message": "Report is outside your assigned scope."})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
|
| 201 |
current_status = str(
|
| 202 |
+
row.get("workflow_status") or row.get("status") or "pending_clinician_review"
|
|
|
|
|
|
|
| 203 |
)
|
| 204 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
if action == "approve":
|
| 206 |
+
if role == "clinician" and current_status not in {"pending_clinician_review", "needs_revision"}:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
return _ok({
|
| 208 |
"status": "invalid_transition",
|
| 209 |
+
"message": "Clinicians can review only reports awaiting clinician review or revision.",
|
|
|
|
|
|
|
|
|
|
| 210 |
})
|
| 211 |
+
if role in {"supervisor", "department_head"} and current_status != "pending_supervisor_review":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
return _ok({
|
| 213 |
"status": "invalid_transition",
|
| 214 |
+
"message": "Supervisor approval requires pending_supervisor_review status.",
|
|
|
|
|
|
|
|
|
|
| 215 |
})
|
|
|
|
| 216 |
if role == "admin" and current_status not in {
|
| 217 |
"pending_clinician_review",
|
| 218 |
"needs_revision",
|
|
|
|
| 221 |
}:
|
| 222 |
return _ok({
|
| 223 |
"status": "invalid_transition",
|
| 224 |
+
"message": "Report cannot be approved from its current status.",
|
|
|
|
|
|
|
|
|
|
| 225 |
})
|
| 226 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
now = datetime.now(timezone.utc).isoformat()
|
|
|
|
| 228 |
patch = {
|
| 229 |
"reviewed_by": identity["uid"],
|
| 230 |
"review_notes": notes,
|
|
|
|
| 232 |
"updated_at": now,
|
| 233 |
}
|
| 234 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
if action == "request_revision":
|
| 236 |
patch.update({
|
| 237 |
"workflow_status": "needs_revision",
|
|
|
|
| 239 |
"visible_to_patient": False,
|
| 240 |
"approval_required": True,
|
| 241 |
})
|
| 242 |
+
elif role == "clinician":
|
| 243 |
+
# A clinician review is not final patient approval.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
patch.update({
|
| 245 |
"workflow_status": "pending_supervisor_review",
|
| 246 |
"status": "pending_supervisor_review",
|
| 247 |
"visible_to_patient": False,
|
| 248 |
"approval_required": True,
|
| 249 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
else:
|
| 251 |
patch.update({
|
| 252 |
"workflow_status": "approved",
|
|
|
|
| 257 |
"approved_at": now,
|
| 258 |
})
|
| 259 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
updated = database._json_request(
|
| 261 |
+
"PATCH", "analyses", patch, params={"id": f"eq.{analysis_id}"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
)
|
| 263 |
+
saved_row = (
|
|
|
|
| 264 |
updated[0]
|
| 265 |
if isinstance(updated, list) and updated
|
| 266 |
+
else {"id": analysis_id, **patch}
|
|
|
|
|
|
|
|
|
|
| 267 |
)
|
| 268 |
|
| 269 |
+
# Return the resolved workflow status so the client knows exactly
|
| 270 |
+
# what state the report is in after this action.
|
| 271 |
return _ok({
|
| 272 |
"status": "success",
|
| 273 |
"message": "Review status updated.",
|
| 274 |
+
"workflow_status": saved_row.get("workflow_status") or patch.get("workflow_status"),
|
| 275 |
+
"visible_to_patient": bool(saved_row.get("visible_to_patient")),
|
| 276 |
+
"analysis": saved_row,
|
| 277 |
})
|
|
|
|
| 278 |
except PermissionError as exc:
|
|
|
|
|
|
|
| 279 |
return _ok({
|
| 280 |
+
"status": "unauthorized" if "unauthorized" in str(exc) else "forbidden",
|
| 281 |
+
"message": str(exc),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
})
|
|
|
|
| 283 |
except Exception:
|
| 284 |
logger.exception("review_report failed")
|
| 285 |
+
return _ok({"status": "error", "message": "Unable to update report review status."})
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
# ----------------------------------------------------------------
|
| 289 |
+
# UPDATE REPORT CONTENT (clinician/supervisor editing before approval)
|
| 290 |
+
# ----------------------------------------------------------------
|
| 291 |
+
def update_report_content_api(
|
| 292 |
+
analysis_id: str,
|
| 293 |
+
findings: str,
|
| 294 |
+
impression: str,
|
| 295 |
+
recommendations_json: str,
|
| 296 |
+
firebase_token: str,
|
| 297 |
+
) -> str:
|
| 298 |
+
"""Allow a clinician/supervisor to edit report content before approval.
|
| 299 |
+
|
| 300 |
+
Approval is final; once approved the report cannot be edited via this
|
| 301 |
+
endpoint. The edit is applied to both report_ar and report_en so both
|
| 302 |
+
language payloads remain consistent, and the editor identity is stored
|
| 303 |
+
in each payload for audit purposes.
|
| 304 |
+
"""
|
| 305 |
+
try:
|
| 306 |
+
identity = _identity(firebase_token)
|
| 307 |
+
role = identity["role"]
|
| 308 |
+
if role not in {"clinician", "supervisor", "department_head", "admin"}:
|
| 309 |
+
return _ok({"status": "forbidden", "message": "Review role required."})
|
| 310 |
+
|
| 311 |
+
analysis_id = (analysis_id or "").strip()
|
| 312 |
+
if not analysis_id:
|
| 313 |
+
return _ok({"status": "invalid_action", "message": "analysis_id is required."})
|
| 314 |
+
|
| 315 |
+
row = _row(analysis_id)
|
| 316 |
+
if not row:
|
| 317 |
+
return _ok({"status": "not_found", "message": "Report not found."})
|
| 318 |
+
if role != "admin" and not _can_read(identity, row):
|
| 319 |
+
return _ok({"status": "forbidden", "message": "Report is outside your assigned scope."})
|
| 320 |
+
|
| 321 |
+
current_status = str(row.get("workflow_status") or "").strip().lower()
|
| 322 |
+
if current_status == "approved":
|
| 323 |
+
return _ok({
|
| 324 |
+
"status": "invalid_transition",
|
| 325 |
+
"message": "Approved reports cannot be edited.",
|
| 326 |
+
})
|
| 327 |
+
|
| 328 |
+
# Parse recommendations JSON. Accept either a JSON array of strings
|
| 329 |
+
# or a single string; fall back to an empty list on parse failure.
|
| 330 |
+
recommendations: list = []
|
| 331 |
+
if recommendations_json:
|
| 332 |
+
try:
|
| 333 |
+
parsed = json.loads(recommendations_json)
|
| 334 |
+
if isinstance(parsed, list):
|
| 335 |
+
recommendations = [str(x) for x in parsed if str(x).strip()]
|
| 336 |
+
elif isinstance(parsed, str) and parsed.strip():
|
| 337 |
+
recommendations = [parsed]
|
| 338 |
+
except Exception:
|
| 339 |
+
recommendations = [recommendations_json]
|
| 340 |
+
|
| 341 |
+
now = datetime.now(timezone.utc).isoformat()
|
| 342 |
+
updated_reports = {}
|
| 343 |
+
|
| 344 |
+
for lang_key in ("report_ar", "report_en"):
|
| 345 |
+
existing = row.get(lang_key) or {}
|
| 346 |
+
if not isinstance(existing, dict):
|
| 347 |
+
existing = {}
|
| 348 |
+
|
| 349 |
+
if (findings or "").strip():
|
| 350 |
+
existing["findings"] = findings.strip()
|
| 351 |
+
if (impression or "").strip():
|
| 352 |
+
existing["impression"] = impression.strip()
|
| 353 |
+
if recommendations:
|
| 354 |
+
existing["recommendations"] = recommendations
|
| 355 |
+
|
| 356 |
+
existing["edited_by"] = identity["uid"]
|
| 357 |
+
existing["edited_at"] = now
|
| 358 |
+
updated_reports[lang_key] = existing
|
| 359 |
+
|
| 360 |
+
patch = {
|
| 361 |
+
"report_ar": updated_reports.get("report_ar"),
|
| 362 |
+
"report_en": updated_reports.get("report_en"),
|
| 363 |
+
"updated_at": now,
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
database._json_request("PATCH", "analyses", patch, params={"id": f"eq.{analysis_id}"})
|
| 367 |
|
| 368 |
return _ok({
|
| 369 |
+
"status": "success",
|
| 370 |
+
"message": "Report content updated.",
|
| 371 |
+
"analysis_id": analysis_id,
|
| 372 |
+
})
|
| 373 |
+
except PermissionError as exc:
|
| 374 |
+
return _ok({
|
| 375 |
+
"status": "unauthorized" if "unauthorized" in str(exc) else "forbidden",
|
| 376 |
+
"message": str(exc),
|
| 377 |
+
})
|
| 378 |
+
except Exception:
|
| 379 |
+
logger.exception("update_report_content failed")
|
| 380 |
+
return _ok({"status": "error", "message": "Unable to update report content."})
|