Improve DICOM UPP relation visualizer
This commit is contained in:
228
数据库Web可视化/app.py
228
数据库Web可视化/app.py
@@ -1,13 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
@@ -23,6 +26,10 @@ PACS_TABLE = os.getenv("PACS_TABLE", "pacs_dicom_files")
|
||||
PACS_SUMMARY_TABLE = os.getenv("PACS_SUMMARY_TABLE", "pacs_dicom_study_summaries")
|
||||
UPP_ASSET_TABLE = os.getenv("UPP_ASSET_TABLE", "upp_exam_assets")
|
||||
UPP_STL_TABLE = os.getenv("UPP_STL_TABLE", "upp_stl_files")
|
||||
USER_TABLE = os.getenv("VISUALIZER_USER_TABLE", "db_visualizer_users")
|
||||
WEB_ADMIN_USER = os.getenv("VISUALIZER_ADMIN_USER", "admin")
|
||||
WEB_ADMIN_PASSWORD = os.getenv("VISUALIZER_ADMIN_PASSWORD", "123456")
|
||||
PACS_VIEWER_URL = os.getenv("PACS_VIEWER_URL", "http://127.0.0.1:8107")
|
||||
|
||||
IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
|
||||
@@ -37,9 +44,27 @@ PACS_TABLE_SQL = ident(PACS_TABLE)
|
||||
PACS_SUMMARY_TABLE_SQL = ident(PACS_SUMMARY_TABLE)
|
||||
UPP_ASSET_TABLE_SQL = ident(UPP_ASSET_TABLE)
|
||||
UPP_STL_TABLE_SQL = ident(UPP_STL_TABLE)
|
||||
USER_TABLE_SQL = ident(USER_TABLE)
|
||||
|
||||
app = FastAPI(title="PACS UPP Database Visualizer")
|
||||
app = FastAPI(title="DICOM UPP Database Visualizer")
|
||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||
TOKENS: dict[str, dict[str, str]] = {}
|
||||
|
||||
|
||||
class LoginPayload(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class UserPayload(BaseModel):
|
||||
username: str
|
||||
password: str = ""
|
||||
role: str = "阅片员"
|
||||
status: str = "启用"
|
||||
|
||||
|
||||
class PasswordPayload(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
def pg_env() -> dict[str, str]:
|
||||
@@ -74,6 +99,10 @@ def sql_literal(value: Any) -> str:
|
||||
return "'" + str(value).replace("'", "''") + "'"
|
||||
|
||||
|
||||
def password_hash(password: str) -> str:
|
||||
return hashlib.sha256(password.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def normalize_ct(value: str) -> str:
|
||||
return re.sub(r"\s+", "", str(value or "").upper())
|
||||
|
||||
@@ -86,6 +115,63 @@ def db_available() -> tuple[bool, str]:
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
def ensure_user_table() -> None:
|
||||
run_psql(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS public.{USER_TABLE_SQL} (
|
||||
username text PRIMARY KEY,
|
||||
password_hash text NOT NULL,
|
||||
role text NOT NULL DEFAULT '阅片员',
|
||||
status text NOT NULL DEFAULT '启用',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
INSERT INTO public.{USER_TABLE_SQL} (username, password_hash, role, status)
|
||||
VALUES ({sql_literal(WEB_ADMIN_USER)}, {sql_literal(password_hash(WEB_ADMIN_PASSWORD))}, '管理员', '启用')
|
||||
ON CONFLICT (username) DO NOTHING;
|
||||
""",
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
|
||||
def fetch_user(username: str) -> dict[str, Any] | None:
|
||||
ensure_user_table()
|
||||
rows = pg_json_rows(
|
||||
f"""
|
||||
SELECT username, password_hash, role, status, created_at, updated_at
|
||||
FROM public.{USER_TABLE_SQL}
|
||||
WHERE username = {sql_literal(username)}
|
||||
LIMIT 1
|
||||
""",
|
||||
timeout=10,
|
||||
)
|
||||
return rows[0] if rows else None
|
||||
|
||||
|
||||
def current_user(authorization: str | None = Header(default=None)) -> dict[str, str]:
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="unauthorized")
|
||||
token = authorization.removeprefix("Bearer ").strip()
|
||||
user = TOKENS.get(token)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="unauthorized")
|
||||
return user
|
||||
|
||||
|
||||
def admin_user(user: dict[str, str] = Depends(current_user)) -> dict[str, str]:
|
||||
if user.get("role") != "管理员":
|
||||
raise HTTPException(status_code=403, detail="forbidden")
|
||||
return user
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup() -> None:
|
||||
try:
|
||||
ensure_user_table()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def relation_cte() -> str:
|
||||
return f"""
|
||||
WITH
|
||||
@@ -168,12 +254,12 @@ def relation_cte() -> str:
|
||||
ps.completed,
|
||||
ps.body_parts,
|
||||
to_jsonb(array_remove(ARRAY[
|
||||
CASE WHEN ps.body_parts ? 'upper_abdomen' THEN '肝胆模型' END,
|
||||
CASE WHEN ps.body_parts ? 'chest' THEN '胸外模型' END,
|
||||
CASE WHEN ps.body_parts ? 'upper_abdomen'
|
||||
OR ps.body_parts ? 'lower_abdomen'
|
||||
OR ps.body_parts ? 'pelvis' THEN '泌尿模型' END
|
||||
], NULL)) AS dicom_models,
|
||||
CASE WHEN ps.body_parts ? 'head_neck' THEN '头颈部' END,
|
||||
CASE WHEN ps.body_parts ? 'chest' THEN '胸部' END,
|
||||
CASE WHEN ps.body_parts ? 'upper_abdomen' THEN '上腹部' END,
|
||||
CASE WHEN ps.body_parts ? 'lower_abdomen' THEN '下腹部' END,
|
||||
CASE WHEN ps.body_parts ? 'pelvis' THEN '盆腔' END
|
||||
], NULL)) AS dicom_body_parts,
|
||||
u.patient_name AS upp_patient_name,
|
||||
u.patient_sex AS upp_patient_sex,
|
||||
u.patient_age,
|
||||
@@ -217,7 +303,7 @@ def relation_cte() -> str:
|
||||
"""
|
||||
|
||||
|
||||
def relation_where(q: str, status: str, algorithm_model: str, dicom_model: str) -> str:
|
||||
def relation_where(q: str, status: str, algorithm_model: str, dicom_part: str) -> str:
|
||||
clauses: list[str] = []
|
||||
query = q.strip()
|
||||
if query:
|
||||
@@ -236,6 +322,7 @@ def relation_where(q: str, status: str, algorithm_model: str, dicom_model: str)
|
||||
f"upp_status ILIKE {literal}",
|
||||
f"exam_description ILIKE {literal}",
|
||||
f"study_description ILIKE {literal}",
|
||||
f"COALESCE(dicom_body_parts::text, '') ILIKE {literal}",
|
||||
]
|
||||
).join(("(", ")"))
|
||||
)
|
||||
@@ -249,14 +336,19 @@ def relation_where(q: str, status: str, algorithm_model: str, dicom_model: str)
|
||||
clauses.append("relation_status = 'stl_only'")
|
||||
elif status == "list_only":
|
||||
clauses.append("relation_status = 'list_only'")
|
||||
elif status == "pending_dicom":
|
||||
clauses.append("pacs_present AND COALESCE(completed, false) IS NOT TRUE")
|
||||
elif status == "undetermined":
|
||||
clauses.append("COALESCE(undetermined_series, 0) > 0")
|
||||
elif status != "all":
|
||||
raise HTTPException(status_code=400, detail="invalid status filter")
|
||||
if algorithm_model:
|
||||
clauses.append(f"algorithm_model = {sql_literal(algorithm_model)}")
|
||||
if dicom_model:
|
||||
clauses.append(f"dicom_models ? {sql_literal(dicom_model)}")
|
||||
if dicom_part:
|
||||
valid_parts = {"head_neck", "chest", "upper_abdomen", "lower_abdomen", "pelvis"}
|
||||
if dicom_part not in valid_parts:
|
||||
raise HTTPException(status_code=400, detail="invalid DICOM part filter")
|
||||
clauses.append(f"body_parts ? {sql_literal(dicom_part)}")
|
||||
return "WHERE " + " AND ".join(clauses) if clauses else ""
|
||||
|
||||
|
||||
@@ -270,8 +362,103 @@ def health() -> str:
|
||||
return "ok"
|
||||
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
def login(payload: LoginPayload) -> dict[str, str]:
|
||||
username = payload.username.strip()
|
||||
try:
|
||||
user = fetch_user(username)
|
||||
except Exception:
|
||||
user = None
|
||||
if not user and username == WEB_ADMIN_USER:
|
||||
user = {"username": WEB_ADMIN_USER, "password_hash": password_hash(WEB_ADMIN_PASSWORD), "role": "管理员", "status": "启用"}
|
||||
if not user or user.get("status") != "启用" or user.get("password_hash") != password_hash(payload.password):
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
token = secrets.token_urlsafe(32)
|
||||
TOKENS[token] = {"username": user["username"], "role": user.get("role", "阅片员")}
|
||||
return {"token": token, "username": user["username"], "role": user.get("role", "阅片员")}
|
||||
|
||||
|
||||
@app.get("/api/auth/me")
|
||||
def me(user: dict[str, str] = Depends(current_user)) -> dict[str, str]:
|
||||
return user
|
||||
|
||||
|
||||
@app.get("/api/settings")
|
||||
def settings(user: dict[str, str] = Depends(admin_user)) -> dict[str, Any]:
|
||||
rows = pg_json_rows(
|
||||
f"""
|
||||
SELECT username, role, status, created_at, updated_at
|
||||
FROM public.{USER_TABLE_SQL}
|
||||
ORDER BY username
|
||||
""",
|
||||
timeout=10,
|
||||
)
|
||||
return {
|
||||
"user": user,
|
||||
"viewer_url": PACS_VIEWER_URL,
|
||||
"users": rows,
|
||||
"database": {"host": PGHOST, "port": PGPORT, "database": PGDATABASE},
|
||||
"tables": {
|
||||
"dicom": PACS_TABLE,
|
||||
"dicom_summary": PACS_SUMMARY_TABLE,
|
||||
"upp_assets": UPP_ASSET_TABLE,
|
||||
"upp_stl": UPP_STL_TABLE,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/settings/users")
|
||||
def save_user(payload: UserPayload, user: dict[str, str] = Depends(admin_user)) -> dict[str, str]:
|
||||
username = payload.username.strip()
|
||||
if not username:
|
||||
raise HTTPException(status_code=400, detail="账号不能为空")
|
||||
role = payload.role if payload.role in {"管理员", "阅片员", "访客"} else "阅片员"
|
||||
status = payload.status if payload.status in {"启用", "停用"} else "启用"
|
||||
existing = fetch_user(username)
|
||||
if existing and not payload.password:
|
||||
run_psql(
|
||||
f"""
|
||||
UPDATE public.{USER_TABLE_SQL}
|
||||
SET role = {sql_literal(role)}, status = {sql_literal(status)}, updated_at = now()
|
||||
WHERE username = {sql_literal(username)}
|
||||
""",
|
||||
timeout=10,
|
||||
)
|
||||
else:
|
||||
if not payload.password:
|
||||
raise HTTPException(status_code=400, detail="新账号需要初始密码")
|
||||
run_psql(
|
||||
f"""
|
||||
INSERT INTO public.{USER_TABLE_SQL} (username, password_hash, role, status, updated_at)
|
||||
VALUES ({sql_literal(username)}, {sql_literal(password_hash(payload.password))}, {sql_literal(role)}, {sql_literal(status)}, now())
|
||||
ON CONFLICT (username) DO UPDATE
|
||||
SET password_hash = EXCLUDED.password_hash,
|
||||
role = EXCLUDED.role,
|
||||
status = EXCLUDED.status,
|
||||
updated_at = now()
|
||||
""",
|
||||
timeout=10,
|
||||
)
|
||||
return {"status": "ok", "updated_by": user["username"]}
|
||||
|
||||
|
||||
@app.put("/api/settings/users/{username}/password")
|
||||
def change_password(username: str, payload: PasswordPayload, user: dict[str, str] = Depends(admin_user)) -> dict[str, str]:
|
||||
if not payload.password:
|
||||
raise HTTPException(status_code=400, detail="密码不能为空")
|
||||
run_psql(
|
||||
f"""
|
||||
UPDATE public.{USER_TABLE_SQL}
|
||||
SET password_hash = {sql_literal(password_hash(payload.password))}, updated_at = now()
|
||||
WHERE username = {sql_literal(username)}
|
||||
""",
|
||||
timeout=10,
|
||||
)
|
||||
return {"status": "ok", "updated_by": user["username"]}
|
||||
|
||||
|
||||
@app.get("/api/status")
|
||||
def status() -> dict[str, Any]:
|
||||
def status(user: dict[str, str] = Depends(current_user)) -> dict[str, Any]:
|
||||
ok, message = db_available()
|
||||
counts: dict[str, Any] = {}
|
||||
if ok:
|
||||
@@ -289,7 +476,8 @@ def status() -> dict[str, Any]:
|
||||
count(*) FILTER (WHERE relation_status = 'pacs_only')::int AS pacs_only_count,
|
||||
count(*) FILTER (WHERE relation_status = 'stl_only')::int AS stl_only_count,
|
||||
count(*) FILTER (WHERE relation_status = 'list_only')::int AS list_only_count,
|
||||
COALESCE(sum(COALESCE(undetermined_series, 0)), 0)::int AS undetermined_series
|
||||
count(*) FILTER (WHERE pacs_present AND COALESCE(completed, false) IS NOT TRUE)::int AS pending_dicom_count,
|
||||
COALESCE(sum(CASE WHEN pacs_present THEN COALESCE(undetermined_series, 0) ELSE 0 END), 0)::int AS pending_dicom_series
|
||||
FROM relation
|
||||
""",
|
||||
timeout=24,
|
||||
@@ -297,9 +485,10 @@ def status() -> dict[str, Any]:
|
||||
counts = rows[0] if rows else {}
|
||||
return {
|
||||
"database": {"ok": ok, "message": message, "host": PGHOST, "database": PGDATABASE},
|
||||
"viewer_url": PACS_VIEWER_URL,
|
||||
"tables": {
|
||||
"pacs": PACS_TABLE,
|
||||
"pacs_summary": PACS_SUMMARY_TABLE,
|
||||
"dicom": PACS_TABLE,
|
||||
"dicom_summary": PACS_SUMMARY_TABLE,
|
||||
"upp_assets": UPP_ASSET_TABLE,
|
||||
"upp_stl": UPP_STL_TABLE,
|
||||
},
|
||||
@@ -312,10 +501,11 @@ def relations(
|
||||
q: str = "",
|
||||
status: str = "all",
|
||||
algorithm_model: str = "",
|
||||
dicom_model: str = "",
|
||||
dicom_part: str = "",
|
||||
limit: int = Query(default=300, ge=1, le=1000),
|
||||
user: dict[str, str] = Depends(current_user),
|
||||
) -> list[dict[str, Any]]:
|
||||
where = relation_where(q, status, algorithm_model, dicom_model)
|
||||
where = relation_where(q, status, algorithm_model, dicom_part)
|
||||
return pg_json_rows(
|
||||
f"""
|
||||
{relation_cte()}
|
||||
@@ -327,7 +517,7 @@ def relations(
|
||||
study_description, exam_description, algorithm_model, upp_status,
|
||||
pacs_series_count, dicom_file_count, annotated_series, undetermined_series, completed,
|
||||
list_present, list_record_count, stl_file_count, stl_file_count_agg,
|
||||
body_parts, dicom_models, segment_categories, segment_families,
|
||||
body_parts, dicom_body_parts, segment_categories, segment_families,
|
||||
pacs_updated_at, upp_updated_at, stl_updated_at
|
||||
FROM relation
|
||||
{where}
|
||||
@@ -344,7 +534,7 @@ def relations(
|
||||
|
||||
|
||||
@app.get("/api/relations/{ct_number}")
|
||||
def relation_detail(ct_number: str) -> dict[str, Any]:
|
||||
def relation_detail(ct_number: str, user: dict[str, str] = Depends(current_user)) -> dict[str, Any]:
|
||||
ct_key = normalize_ct(ct_number)
|
||||
if not ct_key:
|
||||
raise HTTPException(status_code=400, detail="invalid CT number")
|
||||
|
||||
Reference in New Issue
Block a user