Move settings to page and cache study summaries
This commit is contained in:
@@ -8,10 +8,12 @@ import json
|
||||
import os
|
||||
import secrets
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -78,6 +80,9 @@ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||
TOKENS: dict[str, str] = {}
|
||||
STUDY_CACHE: dict[str, dict[str, Any]] = {}
|
||||
STACK_CACHE: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||
SUMMARY_REFRESH_LOCK = threading.Lock()
|
||||
SUMMARY_REFRESH_STATE: dict[str, Any] = {"running": False, "started_at": "", "finished_at": "", "message": "未运行"}
|
||||
SUMMARY_SCHEDULER_STARTED = False
|
||||
|
||||
|
||||
class LoginIn(BaseModel):
|
||||
@@ -107,6 +112,10 @@ class AIRequest(BaseModel):
|
||||
sample_count: int = 3
|
||||
|
||||
|
||||
class AISettingsIn(BaseModel):
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class UserIn(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
@@ -266,6 +275,66 @@ def ensure_user_table() -> None:
|
||||
pg_scalar(sql)
|
||||
|
||||
|
||||
def ensure_settings_table() -> None:
|
||||
sql = """
|
||||
CREATE TABLE IF NOT EXISTS public.pacs_web_settings (
|
||||
key text PRIMARY KEY,
|
||||
value text NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
INSERT INTO public.pacs_web_settings (key, value)
|
||||
VALUES ('ai_enabled', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
"""
|
||||
pg_scalar(sql)
|
||||
|
||||
|
||||
def ensure_study_summary_table() -> None:
|
||||
sql = """
|
||||
CREATE TABLE IF NOT EXISTS public.pacs_dicom_study_summaries (
|
||||
ct_number text PRIMARY KEY,
|
||||
series_count integer NOT NULL DEFAULT 0,
|
||||
annotated_series integer NOT NULL DEFAULT 0,
|
||||
unannotated_series integer NOT NULL DEFAULT 0,
|
||||
refreshed_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
"""
|
||||
pg_scalar(sql)
|
||||
|
||||
|
||||
def setting_value(key: str, default: str = "") -> str:
|
||||
try:
|
||||
ensure_settings_table()
|
||||
rows = pg_json_rows(
|
||||
f"""
|
||||
SELECT value
|
||||
FROM public.pacs_web_settings
|
||||
WHERE key = {sql_literal(key)}
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
return str(rows[0]["value"]) if rows else default
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def set_setting_value(key: str, value: str) -> None:
|
||||
ensure_settings_table()
|
||||
pg_scalar(
|
||||
f"""
|
||||
INSERT INTO public.pacs_web_settings (key, value, updated_at)
|
||||
VALUES ({sql_literal(key)}, {sql_literal(value)}, now())
|
||||
ON CONFLICT (key) DO UPDATE SET
|
||||
value = EXCLUDED.value,
|
||||
updated_at = now()
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def ai_enabled() -> bool:
|
||||
return setting_value("ai_enabled", "true").lower() in {"true", "1", "yes", "on"}
|
||||
|
||||
|
||||
def web_users() -> list[dict[str, Any]]:
|
||||
try:
|
||||
ensure_user_table()
|
||||
@@ -280,6 +349,30 @@ def web_users() -> list[dict[str, Any]]:
|
||||
return [{"username": WEB_USER, "role": "管理员", "status": "启用"}]
|
||||
|
||||
|
||||
def web_user_record(username: str) -> dict[str, Any]:
|
||||
try:
|
||||
ensure_user_table()
|
||||
rows = pg_json_rows(
|
||||
f"""
|
||||
SELECT username, role, status
|
||||
FROM public.pacs_web_users
|
||||
WHERE username = {sql_literal(username)}
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
if rows:
|
||||
return rows[0]
|
||||
except Exception:
|
||||
pass
|
||||
if username == WEB_USER:
|
||||
return {"username": WEB_USER, "role": "管理员", "status": "启用"}
|
||||
return {"username": username, "role": "访客", "status": "启用"}
|
||||
|
||||
|
||||
def user_role(username: str) -> str:
|
||||
return str(web_user_record(username).get("role") or "访客")
|
||||
|
||||
|
||||
def authenticate_web_user(username: str, password: str) -> bool:
|
||||
try:
|
||||
ok, _ = db_available()
|
||||
@@ -303,10 +396,16 @@ def authenticate_web_user(username: str, password: str) -> bool:
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup() -> None:
|
||||
global SUMMARY_SCHEDULER_STARTED
|
||||
ok, _ = db_available()
|
||||
if ok:
|
||||
ensure_annotation_table()
|
||||
ensure_user_table()
|
||||
ensure_settings_table()
|
||||
ensure_study_summary_table()
|
||||
if not SUMMARY_SCHEDULER_STARTED:
|
||||
SUMMARY_SCHEDULER_STARTED = True
|
||||
threading.Thread(target=summary_scheduler_loop, daemon=True).start()
|
||||
|
||||
|
||||
def require_auth(authorization: str | None = Header(default=None), access_token: str = Query(default="")) -> str:
|
||||
@@ -321,20 +420,36 @@ def require_auth(authorization: str | None = Header(default=None), access_token:
|
||||
return user
|
||||
|
||||
|
||||
def require_admin(user: str = Depends(require_auth)) -> str:
|
||||
if user_role(user) != "管理员":
|
||||
raise HTTPException(status_code=403, detail="仅管理员可访问设置")
|
||||
return user
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index() -> FileResponse:
|
||||
return FileResponse(STATIC_DIR / "index.html")
|
||||
|
||||
|
||||
@app.get("/settings")
|
||||
def settings_page() -> FileResponse:
|
||||
return FileResponse(STATIC_DIR / "index.html")
|
||||
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
def login(data: LoginIn) -> dict[str, str]:
|
||||
if authenticate_web_user(data.username, data.password):
|
||||
token = secrets.token_urlsafe(32)
|
||||
TOKENS[token] = data.username
|
||||
return {"token": token, "username": data.username}
|
||||
return {"token": token, "username": data.username, "role": user_role(data.username)}
|
||||
raise HTTPException(status_code=401, detail="invalid credentials")
|
||||
|
||||
|
||||
@app.get("/api/auth/me")
|
||||
def me(user: str = Depends(require_auth)) -> dict[str, str]:
|
||||
return {"username": user, "role": user_role(user)}
|
||||
|
||||
|
||||
@app.get("/api/status")
|
||||
def status() -> dict[str, Any]:
|
||||
db_ok, db_message = db_available()
|
||||
@@ -347,11 +462,46 @@ def status() -> dict[str, Any]:
|
||||
return {
|
||||
"database": {"ok": db_ok, "message": db_message, "host": PGHOST, "database": PGDATABASE, "table": PGTABLE, "rows": table_count},
|
||||
"dicom": {"processed_root": str(PROCESSED_ROOT), "exists": PROCESSED_ROOT.exists()},
|
||||
"ai": {"configured": bool(KIMI_API_KEY), "provider": "Kimi", "name": KIMI_API_NAME, "model": KIMI_MODEL},
|
||||
"ai": {"configured": bool(KIMI_API_KEY), "enabled": ai_enabled(), "provider": "Kimi", "name": KIMI_API_NAME, "model": KIMI_MODEL},
|
||||
"server_time": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
|
||||
|
||||
def annotation_counts_by_study() -> dict[str, int]:
|
||||
try:
|
||||
annotations = pg_json_rows(
|
||||
"""
|
||||
SELECT ct_number, count(*)::int AS annotated_series
|
||||
FROM public.pacs_dicom_series_annotations
|
||||
WHERE skipped IS TRUE
|
||||
OR jsonb_array_length(body_parts) > 0
|
||||
OR plain_ct IS TRUE
|
||||
OR NULLIF(notes, '') IS NOT NULL
|
||||
OR ai_result IS NOT NULL
|
||||
GROUP BY ct_number
|
||||
""",
|
||||
timeout=8,
|
||||
)
|
||||
except Exception:
|
||||
return {}
|
||||
return {row["ct_number"]: int(row["annotated_series"] or 0) for row in annotations}
|
||||
|
||||
|
||||
def summary_rows_by_study() -> dict[str, dict[str, Any]]:
|
||||
try:
|
||||
ensure_study_summary_table()
|
||||
rows = pg_json_rows(
|
||||
"""
|
||||
SELECT ct_number, series_count, annotated_series, unannotated_series, refreshed_at
|
||||
FROM public.pacs_dicom_study_summaries
|
||||
""",
|
||||
timeout=8,
|
||||
)
|
||||
except Exception:
|
||||
return {}
|
||||
return {row["ct_number"]: row for row in rows}
|
||||
|
||||
|
||||
@app.get("/api/studies")
|
||||
def studies(_: str = Depends(require_auth), q: str = "", limit: int = 200) -> list[dict[str, Any]]:
|
||||
where = ""
|
||||
@@ -370,24 +520,15 @@ def studies(_: str = Depends(require_auth), q: str = "", limit: int = 200) -> li
|
||||
LIMIT {int(limit)}
|
||||
"""
|
||||
)
|
||||
annotations = pg_json_rows(
|
||||
"""
|
||||
SELECT ct_number, count(*)::int AS annotated_series
|
||||
FROM public.pacs_dicom_series_annotations
|
||||
WHERE skipped IS TRUE
|
||||
OR jsonb_array_length(body_parts) > 0
|
||||
OR plain_ct IS TRUE
|
||||
OR NULLIF(notes, '') IS NOT NULL
|
||||
OR ai_result IS NOT NULL
|
||||
GROUP BY ct_number
|
||||
""",
|
||||
timeout=8,
|
||||
)
|
||||
annotation_map = {row["ct_number"]: row["annotated_series"] for row in annotations}
|
||||
annotation_map = annotation_counts_by_study()
|
||||
summary_map = summary_rows_by_study()
|
||||
for row in rows:
|
||||
total_series = int(row.get("series_count") or 0)
|
||||
row["annotated_series"] = min(total_series, int(annotation_map.get(row["ct_number"], 0)))
|
||||
summary = summary_map.get(row["ct_number"])
|
||||
total_series = int((summary or {}).get("series_count") or row.get("series_count") or 0)
|
||||
row["series_count"] = total_series
|
||||
row["annotated_series"] = min(total_series, int((summary or {}).get("annotated_series") if summary else annotation_map.get(row["ct_number"], 0)))
|
||||
row["unannotated_series"] = max(0, total_series - int(row["annotated_series"]))
|
||||
row["summary_updated_at"] = (summary or {}).get("refreshed_at", "")
|
||||
return rows
|
||||
|
||||
|
||||
@@ -790,6 +931,7 @@ def scan_study(ct_number: str) -> dict[str, Any]:
|
||||
series_list.sort(key=lambda row: (1 if row["annotation"].get("skipped") else 0, *series_time_key(row)))
|
||||
cached = {"cached_at": time.time(), "study": study, "series": series_list, "files": file_map}
|
||||
STUDY_CACHE[ct_number] = cached
|
||||
save_study_summary(study_summary_payload(cached))
|
||||
return cached
|
||||
|
||||
|
||||
@@ -814,6 +956,82 @@ def study_summary_payload(data: dict[str, Any]) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def save_study_summary(summary: dict[str, Any]) -> None:
|
||||
try:
|
||||
ensure_study_summary_table()
|
||||
pg_scalar(
|
||||
f"""
|
||||
INSERT INTO public.pacs_dicom_study_summaries (
|
||||
ct_number, series_count, annotated_series, unannotated_series, refreshed_at
|
||||
)
|
||||
VALUES (
|
||||
{sql_literal(summary.get('ct_number', ''))},
|
||||
{int(summary.get('series_count') or 0)},
|
||||
{int(summary.get('annotated_series') or 0)},
|
||||
{int(summary.get('unannotated_series') or 0)},
|
||||
now()
|
||||
)
|
||||
ON CONFLICT (ct_number) DO UPDATE SET
|
||||
series_count = EXCLUDED.series_count,
|
||||
annotated_series = EXCLUDED.annotated_series,
|
||||
unannotated_series = EXCLUDED.unannotated_series,
|
||||
refreshed_at = now()
|
||||
""",
|
||||
timeout=8,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def all_ct_numbers() -> list[str]:
|
||||
rows = pg_json_rows(
|
||||
f"""
|
||||
SELECT ct_number
|
||||
FROM public.{PGTABLE}
|
||||
ORDER BY study_date DESC NULLS LAST, study_time DESC NULLS LAST, ct_number
|
||||
""",
|
||||
timeout=20,
|
||||
)
|
||||
return [str(row["ct_number"]) for row in rows if row.get("ct_number")]
|
||||
|
||||
|
||||
def refresh_all_study_summaries(reason: str = "manual") -> None:
|
||||
if not SUMMARY_REFRESH_LOCK.acquire(blocking=False):
|
||||
return
|
||||
SUMMARY_REFRESH_STATE.update({"running": True, "started_at": time.strftime("%Y-%m-%d %H:%M:%S"), "finished_at": "", "message": f"{reason} 刷新中"})
|
||||
try:
|
||||
numbers = all_ct_numbers()
|
||||
for index, ct_number in enumerate(numbers, start=1):
|
||||
STUDY_CACHE.pop(ct_number, None)
|
||||
try:
|
||||
data = scan_study(ct_number)
|
||||
save_study_summary(study_summary_payload(data))
|
||||
SUMMARY_REFRESH_STATE["message"] = f"{reason} 刷新中:{index}/{len(numbers)}"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
SUMMARY_REFRESH_STATE["message"] = f"{ct_number} 刷新失败:{exc}"
|
||||
SUMMARY_REFRESH_STATE.update({"finished_at": time.strftime("%Y-%m-%d %H:%M:%S"), "message": f"{reason} 刷新完成:{len(numbers)} 个检查"})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
SUMMARY_REFRESH_STATE.update({"finished_at": time.strftime("%Y-%m-%d %H:%M:%S"), "message": f"{reason} 刷新失败:{exc}"})
|
||||
finally:
|
||||
SUMMARY_REFRESH_STATE["running"] = False
|
||||
SUMMARY_REFRESH_LOCK.release()
|
||||
|
||||
|
||||
def next_four_am() -> datetime:
|
||||
now = datetime.now()
|
||||
target = now.replace(hour=4, minute=0, second=0, microsecond=0)
|
||||
if target <= now:
|
||||
target += timedelta(days=1)
|
||||
return target
|
||||
|
||||
|
||||
def summary_scheduler_loop() -> None:
|
||||
while True:
|
||||
sleep_seconds = max(60.0, (next_four_am() - datetime.now()).total_seconds())
|
||||
time.sleep(sleep_seconds)
|
||||
refresh_all_study_summaries("凌晨4点定时")
|
||||
|
||||
|
||||
@app.get("/api/studies/{ct_number}/series")
|
||||
def series(ct_number: str, _: str = Depends(require_auth)) -> dict[str, Any]:
|
||||
data = scan_study(ct_number)
|
||||
@@ -1260,8 +1478,8 @@ def parse_ai_json(content: str) -> dict[str, Any]:
|
||||
|
||||
@app.post("/api/series/{ct_number}/{series_uid}/ai")
|
||||
def ai_classify(ct_number: str, series_uid: str, _: AIRequest, user: str = Depends(require_auth)) -> dict[str, Any]:
|
||||
if not KIMI_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="KIMI_API_KEY 未配置")
|
||||
if not KIMI_API_KEY or not ai_enabled():
|
||||
raise HTTPException(status_code=400, detail="AI 功能未启用或 KIMI_API_KEY 未配置")
|
||||
study = scan_study(ct_number)
|
||||
series_row = next((row for row in study["series"] if row["series_uid"] == series_uid), None)
|
||||
if not series_row:
|
||||
@@ -1357,20 +1575,33 @@ def ai_classify(ct_number: str, series_uid: str, _: AIRequest, user: str = Depen
|
||||
|
||||
|
||||
@app.get("/api/settings")
|
||||
def settings(_: str = Depends(require_auth)) -> dict[str, Any]:
|
||||
def settings(_: str = Depends(require_admin)) -> dict[str, Any]:
|
||||
return {
|
||||
"users": web_users(),
|
||||
"roles": [{"name": name, "permissions": permissions} for name, permissions in ROLES.items()],
|
||||
"database": {"host": PGHOST, "port": PGPORT, "database": PGDATABASE, "table": PGTABLE},
|
||||
"ai": {"provider": "Kimi", "name": KIMI_API_NAME, "model": KIMI_MODEL, "configured": bool(KIMI_API_KEY), "url": KIMI_API_URL},
|
||||
"ai": {"provider": "Kimi", "name": KIMI_API_NAME, "model": KIMI_MODEL, "configured": bool(KIMI_API_KEY), "enabled": ai_enabled(), "url": KIMI_API_URL},
|
||||
"dicom": {"processed_root": str(PROCESSED_ROOT)},
|
||||
"summary_refresh": SUMMARY_REFRESH_STATE,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/settings/ai")
|
||||
def update_ai_settings(data: AISettingsIn, _: str = Depends(require_admin)) -> dict[str, Any]:
|
||||
set_setting_value("ai_enabled", "true" if data.enabled else "false")
|
||||
return {"ok": True, "enabled": data.enabled}
|
||||
|
||||
|
||||
@app.post("/api/settings/refresh-summaries")
|
||||
def refresh_summaries_now(_: str = Depends(require_admin)) -> dict[str, Any]:
|
||||
if SUMMARY_REFRESH_STATE.get("running"):
|
||||
return {"ok": True, **SUMMARY_REFRESH_STATE}
|
||||
threading.Thread(target=refresh_all_study_summaries, args=("手动",), daemon=True).start()
|
||||
return {"ok": True, "running": True, "message": "已开始后台刷新"}
|
||||
|
||||
|
||||
@app.post("/api/settings/users")
|
||||
def create_user(data: UserIn, user: str = Depends(require_auth)) -> dict[str, Any]:
|
||||
if user != WEB_USER:
|
||||
raise HTTPException(status_code=403, detail="仅管理员可创建用户")
|
||||
def create_user(data: UserIn, user: str = Depends(require_admin)) -> dict[str, Any]:
|
||||
username = data.username.strip()
|
||||
if not username or len(username) > 64:
|
||||
raise HTTPException(status_code=400, detail="账号格式不正确")
|
||||
|
||||
Reference in New Issue
Block a user