- 增强 DICOM/视频项目导入与演示数据:DICOM 按文件名自然顺序处理,导入后展示上传与解析任务进度,恢复演示出厂设置保留演示视频和演示 DICOM 项目,并补充 demo media seed 逻辑。 - 完善项目管理:项目支持重命名、删除、复制,删除使用站内确认弹窗,复制支持新项目重置和全内容复制,DICOM 项目不显示生成帧入口。 - 完善 GT Mask 与导出链路:只支持 8-bit maskid 图导入,非法/全背景图明确拒绝,尺寸自动适配,高精度 polygon 回显;统一导出默认当前帧,GT_label 使用 uint8 和真实 maskid,待分类 maskid 0 与背景一致。 - 完善分割工作区交互:新增画笔和橡皮擦并支持尺寸控制,移除创建点/线段入口,工具栏按类别分隔,AI 智能分割使用明确 AI 图标,取消黄色 seed point,清空/删除传播 mask 后同步清理空帧时间轴状态。 - 完善传播与时间轴:自动传播使用 SAM 2.1 权重任务,参考帧无遮罩时提示,传播历史按同一蓝色系递进变暗,删除/清空传播链时保留人工或独立 AI 标注来源。 - 完善模板库:新增头颈部 CT 分割默认模板,所有模板保留 maskid 0 待分类,支持鼠标复制模板、拖拽层级、JSON 批量导入预览、删除 label 和站内删除确认。 - 完善用户与高风险确认:用户改密码、删除用户、恢复演示出厂设置和清空人工/AI 标注帧均改为站内确认交互,避免浏览器原生 prompt/confirm。 - 补充前后端测试与文档:更新项目、模板、GT 导入、导出、传播、DICOM、用户管理等测试,并同步 README、AGENTS 和 doc 下实现/契约/测试计划文档。
284 lines
10 KiB
Python
284 lines
10 KiB
Python
"""Administrator-only user and audit management endpoints."""
|
|
|
|
import os
|
|
from typing import List
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.orm import Session
|
|
|
|
from config import settings
|
|
from database import get_db
|
|
from models import Annotation, AuditLog, Frame, Mask, ProcessingTask, Project, Template, User
|
|
from routers.auth import ensure_default_admin, hash_password, require_admin, write_audit_log
|
|
from schemas import (
|
|
AdminUserCreate,
|
|
AdminUserUpdate,
|
|
AuditLogOut,
|
|
DemoFactoryResetOut,
|
|
DemoFactoryResetRequest,
|
|
UserOut,
|
|
)
|
|
from services.demo_media import (
|
|
DEMO_DICOM_PROJECT_NAME,
|
|
DEMO_VIDEO_PROJECT_NAME,
|
|
create_parsed_dicom_demo_project,
|
|
create_unparsed_video_demo_project,
|
|
demo_dicom_files,
|
|
)
|
|
|
|
router = APIRouter(prefix="/api/admin", tags=["Admin"])
|
|
|
|
VALID_ROLES = {"admin", "annotator", "viewer"}
|
|
DEMO_RESET_CONFIRMATION = "RESET_DEMO_FACTORY"
|
|
DEMO_PROJECT_NAME = DEMO_DICOM_PROJECT_NAME
|
|
|
|
|
|
def _normalize_role(role: str | None) -> str:
|
|
normalized = (role or "annotator").strip().lower()
|
|
if normalized not in VALID_ROLES:
|
|
raise HTTPException(status_code=400, detail=f"Unsupported role: {role}")
|
|
return normalized
|
|
|
|
|
|
@router.get("/users", response_model=List[UserOut], summary="List users")
|
|
def list_users(
|
|
db: Session = Depends(get_db),
|
|
admin_user: User = Depends(require_admin),
|
|
) -> List[User]:
|
|
"""Return all users for the administrator console."""
|
|
_ = admin_user
|
|
return db.query(User).order_by(User.id).all()
|
|
|
|
|
|
@router.post(
|
|
"/users",
|
|
response_model=UserOut,
|
|
status_code=status.HTTP_201_CREATED,
|
|
summary="Create user",
|
|
)
|
|
def create_user(
|
|
payload: AdminUserCreate,
|
|
db: Session = Depends(get_db),
|
|
admin_user: User = Depends(require_admin),
|
|
) -> User:
|
|
"""Create a user with an initial password and role."""
|
|
username = payload.username.strip()
|
|
if not username:
|
|
raise HTTPException(status_code=400, detail="Username is required")
|
|
if len(payload.password) < 6:
|
|
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
|
|
user = User(
|
|
username=username,
|
|
password_hash=hash_password(payload.password),
|
|
role=_normalize_role(payload.role),
|
|
is_active=1 if payload.is_active else 0,
|
|
)
|
|
db.add(user)
|
|
try:
|
|
db.commit()
|
|
except IntegrityError as exc:
|
|
db.rollback()
|
|
raise HTTPException(status_code=409, detail="Username already exists") from exc
|
|
db.refresh(user)
|
|
write_audit_log(
|
|
db,
|
|
actor=admin_user,
|
|
action="admin.user_created",
|
|
target_type="user",
|
|
target_id=user.id,
|
|
detail={"username": user.username, "role": user.role, "is_active": bool(user.is_active)},
|
|
)
|
|
return user
|
|
|
|
|
|
@router.patch("/users/{user_id}", response_model=UserOut, summary="Update user")
|
|
def update_user(
|
|
user_id: int,
|
|
payload: AdminUserUpdate,
|
|
db: Session = Depends(get_db),
|
|
admin_user: User = Depends(require_admin),
|
|
) -> User:
|
|
"""Update username, password, role or active state."""
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
updates = payload.model_dump(exclude_unset=True)
|
|
audit_detail: dict = {"before": {"username": user.username, "role": user.role, "is_active": bool(user.is_active)}}
|
|
if "username" in updates:
|
|
username = (updates["username"] or "").strip()
|
|
if not username:
|
|
raise HTTPException(status_code=400, detail="Username is required")
|
|
user.username = username
|
|
if "password" in updates:
|
|
password = updates["password"] or ""
|
|
if len(password) < 6:
|
|
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
|
|
user.password_hash = hash_password(password)
|
|
if "role" in updates:
|
|
next_role = _normalize_role(updates["role"])
|
|
if user.id == admin_user.id and next_role != "admin":
|
|
raise HTTPException(status_code=400, detail="Cannot remove your own admin role")
|
|
user.role = next_role
|
|
if "is_active" in updates:
|
|
if user.id == admin_user.id and not updates["is_active"]:
|
|
raise HTTPException(status_code=400, detail="Cannot deactivate yourself")
|
|
user.is_active = 1 if updates["is_active"] else 0
|
|
|
|
try:
|
|
db.commit()
|
|
except IntegrityError as exc:
|
|
db.rollback()
|
|
raise HTTPException(status_code=409, detail="Username already exists") from exc
|
|
db.refresh(user)
|
|
audit_detail["after"] = {"username": user.username, "role": user.role, "is_active": bool(user.is_active)}
|
|
audit_detail["password_changed"] = "password" in updates
|
|
write_audit_log(
|
|
db,
|
|
actor=admin_user,
|
|
action="admin.user_updated",
|
|
target_type="user",
|
|
target_id=user.id,
|
|
detail=audit_detail,
|
|
)
|
|
return user
|
|
|
|
|
|
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete user")
|
|
def delete_user(
|
|
user_id: int,
|
|
db: Session = Depends(get_db),
|
|
admin_user: User = Depends(require_admin),
|
|
) -> None:
|
|
"""Delete a user when it is safe to remove the account."""
|
|
if user_id == admin_user.id:
|
|
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
owned_project_count = db.query(Project).filter(Project.owner_user_id == user_id).count()
|
|
if owned_project_count:
|
|
raise HTTPException(status_code=409, detail="User owns projects; deactivate or migrate projects first")
|
|
username = user.username
|
|
db.delete(user)
|
|
db.commit()
|
|
write_audit_log(
|
|
db,
|
|
actor=admin_user,
|
|
action="admin.user_deleted",
|
|
target_type="user",
|
|
target_id=user_id,
|
|
detail={"username": username},
|
|
)
|
|
return None
|
|
|
|
|
|
@router.get("/audit-logs", response_model=List[AuditLogOut], summary="List audit logs")
|
|
def list_audit_logs(
|
|
limit: int = 100,
|
|
db: Session = Depends(get_db),
|
|
admin_user: User = Depends(require_admin),
|
|
) -> List[AuditLog]:
|
|
"""Return recent audit events for administrators."""
|
|
_ = admin_user
|
|
safe_limit = min(max(int(limit or 100), 1), 500)
|
|
return db.query(AuditLog).order_by(AuditLog.created_at.desc(), AuditLog.id.desc()).limit(safe_limit).all()
|
|
|
|
|
|
@router.post(
|
|
"/demo-factory-reset",
|
|
response_model=DemoFactoryResetOut,
|
|
summary="Reset demo data to factory defaults",
|
|
)
|
|
def reset_demo_factory(
|
|
payload: DemoFactoryResetRequest,
|
|
db: Session = Depends(get_db),
|
|
admin_user: User = Depends(require_admin),
|
|
) -> dict:
|
|
"""Reset a demo deployment to one admin account, the demo video, and the demo DICOM project."""
|
|
if payload.confirmation != DEMO_RESET_CONFIRMATION:
|
|
raise HTTPException(status_code=400, detail="Invalid reset confirmation")
|
|
|
|
if not os.path.exists(settings.demo_video_path):
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=f"Demo video not found: {settings.demo_video_path}",
|
|
)
|
|
if not demo_dicom_files(settings.demo_dicom_dir):
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=f"Demo DICOM series not found: {settings.demo_dicom_dir}",
|
|
)
|
|
|
|
requested_by = admin_user.username
|
|
preserved_admin = ensure_default_admin(db)
|
|
preserved_admin.username = settings.default_admin_username
|
|
preserved_admin.password_hash = hash_password(settings.default_admin_password)
|
|
preserved_admin.role = "admin"
|
|
preserved_admin.is_active = 1
|
|
db.flush()
|
|
|
|
deleted_counts = {
|
|
"masks": db.query(Mask).delete(synchronize_session=False),
|
|
"annotations": db.query(Annotation).delete(synchronize_session=False),
|
|
"frames": db.query(Frame).delete(synchronize_session=False),
|
|
"tasks": db.query(ProcessingTask).delete(synchronize_session=False),
|
|
"projects": db.query(Project).delete(synchronize_session=False),
|
|
"user_templates": db.query(Template).filter(Template.owner_user_id.is_not(None)).delete(synchronize_session=False),
|
|
"audit_logs": db.query(AuditLog).delete(synchronize_session=False),
|
|
"users": db.query(User).filter(User.id != preserved_admin.id).delete(synchronize_session=False),
|
|
}
|
|
db.flush()
|
|
db.expunge_all()
|
|
|
|
preserved_admin = db.query(User).filter(User.username == settings.default_admin_username).first()
|
|
if not preserved_admin:
|
|
raise HTTPException(status_code=500, detail="Default admin was not preserved")
|
|
|
|
video_project = create_unparsed_video_demo_project(
|
|
db,
|
|
owner=preserved_admin,
|
|
video_path=settings.demo_video_path,
|
|
project_name=DEMO_VIDEO_PROJECT_NAME,
|
|
)
|
|
video_project.frame_count = 0
|
|
|
|
dicom_project = create_parsed_dicom_demo_project(
|
|
db,
|
|
owner=preserved_admin,
|
|
dicom_dir=settings.demo_dicom_dir,
|
|
project_name=DEMO_PROJECT_NAME,
|
|
)
|
|
db.refresh(preserved_admin)
|
|
db.refresh(video_project)
|
|
db.refresh(dicom_project)
|
|
video_project.frame_count = len(video_project.frames)
|
|
dicom_project.frame_count = len(dicom_project.frames)
|
|
projects = [video_project, dicom_project]
|
|
|
|
write_audit_log(
|
|
db,
|
|
actor=preserved_admin,
|
|
action="admin.demo_factory_reset",
|
|
target_type="project",
|
|
target_id=dicom_project.id,
|
|
detail={
|
|
"project_names": [project.name for project in projects],
|
|
"video_path": video_project.video_path,
|
|
"dicom_path": dicom_project.video_path,
|
|
"source_types": [project.source_type for project in projects],
|
|
"frame_counts": {project.name: len(project.frames) for project in projects},
|
|
"deleted_counts": deleted_counts,
|
|
"requested_by": requested_by,
|
|
},
|
|
)
|
|
|
|
return {
|
|
"admin_user": preserved_admin,
|
|
"project": dicom_project,
|
|
"projects": projects,
|
|
"deleted_counts": deleted_counts,
|
|
"message": "演示环境已恢复出厂设置",
|
|
}
|