收敛用户角色并共享项目库
- 后端限制系统只保留默认 admin 管理员,新建用户固定为标注员,并拒绝观察员或额外管理员角色。 - 将项目、帧、媒体解析、AI 标注、任务、Dashboard 和导出接口改为共享项目库访问,标注员具备同等项目管理和标注能力。 - 前端用户管理移除角色选择和观察员入口,只展示唯一管理员与标注员状态。 - 更新后端/前端测试,覆盖唯一 admin、旧 viewer 归一为标注员、用户删除和共享项目库访问。 - 同步更新 AGENTS 与 doc 文档中的角色权限、共享项目库和测试计划说明。
This commit is contained in:
12
AGENTS.md
12
AGENTS.md
@@ -238,9 +238,9 @@ uvicorn main:app --host 0.0.0.0 --port 8000 --reload
|
|||||||
|
|
||||||
## 主要业务流程
|
## 主要业务流程
|
||||||
|
|
||||||
1. 登录:`Login.tsx` 调用 `POST /api/auth/login`,后端用 `users` 表和密码哈希校验凭证,默认启动时会种子化开发管理员 `admin / 123456`;成功后返回签名 JWT,`GET /api/auth/me` 可读取当前用户;角色包括 `admin`、`annotator`、`viewer`,写入类业务接口要求 `admin/annotator`,用户管理后台要求 `admin`。
|
1. 登录:`Login.tsx` 调用 `POST /api/auth/login`,后端用 `users` 表和密码哈希校验凭证,默认启动时会种子化唯一管理员 `admin / 123456`;成功后返回签名 JWT,`GET /api/auth/me` 可读取当前用户;角色只包括 `admin` 和 `annotator`,非默认 admin 的历史管理员或旧 `viewer` 会归一为 `annotator`;写入类业务接口要求 `admin/annotator`,用户管理、审计日志和演示出厂设置后台仅 `admin` 可用。
|
||||||
2. 用户管理:`Sidebar` 仅对 `admin` 显示“用户管理”,`UserAdmin.tsx` 调用 `/api/admin/users` 新增、停用/启用、改角色、改密码和删除无项目用户,并调用 `/api/admin/audit-logs` 展示登录和管理操作审计;演示部署可通过“恢复演示出厂设置”二次确认后调用 `/api/admin/demo-factory-reset`,清空演示数据,只保留默认 admin、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目,同时按内置权威定义恢复“腹腔镜胆囊切除术”和“头颈部CT分割”系统模板,缺失的会重建,被修改或删减的语义分类树会覆盖回默认状态。
|
2. 用户管理:`Sidebar` 仅对 `admin` 显示“用户管理”,`UserAdmin.tsx` 调用 `/api/admin/users` 新增标注员、停用/启用、改密码和删除用户,并调用 `/api/admin/audit-logs` 展示登录和管理操作审计;系统不允许新增第二个管理员,也不再支持观察员角色;演示部署可通过“恢复演示出厂设置”二次确认后调用 `/api/admin/demo-factory-reset`,清空演示数据,只保留默认 admin、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目,同时按内置权威定义恢复“腹腔镜胆囊切除术”和“头颈部CT分割”系统模板,缺失的会重建,被修改或删减的语义分类树会覆盖回默认状态。
|
||||||
3. 项目管理:`ProjectLibrary.tsx` 调用项目 API 创建项目、拉取列表、重命名项目、复制项目和删除项目;项目卡片删除按钮旁提供复制入口,复制时可选择“新项目重置”(复制项目媒体和已生成帧序列,但清空标注/mask)或“全内容复制”(复制项目、帧序列、标注和关联 mask 元数据),任务运行历史不复制;删除当前项目后会清空工作区当前项目、帧、mask 和选区。
|
3. 项目管理:`ProjectLibrary.tsx` 调用项目 API 创建项目、拉取列表、重命名项目、复制项目和删除项目;项目库为所有登录用户共享,标注员和管理员在项目创建、导入、解析、标注、AI 推理、任务查看、导出和删除方面能力一致;项目卡片删除按钮旁提供复制入口,复制时可选择“新项目重置”(复制项目媒体和已生成帧序列,但清空标注/mask)或“全内容复制”(复制项目、帧序列、标注和关联 mask 元数据),任务运行历史不复制;删除当前项目后会清空工作区当前项目、帧、mask 和选区。
|
||||||
4. 上传资源:视频走 `/api/media/upload`,只上传源文件并关联项目,不自动拆帧;项目库在视频上传期间显示导入进度条、百分比和已上传字节。只有视频项目在尚未生成帧、未处于项目名称编辑状态且未解析中时显示“生成帧”,DICOM 项目不显示生成帧入口;DICOM 批量走 `/api/media/upload/dicom`,前端和后端都会按文件名自然顺序排序 `.dcm` 文件,避免 `10.dcm` 排在 `2.dcm` 前导致切片错位;DICOM 上传期间显示导入进度条、本次有效文件数量和已上传字节,上传完成后轮询解析任务进度直到完成、失败或取消。
|
4. 上传资源:视频走 `/api/media/upload`,只上传源文件并关联项目,不自动拆帧;项目库在视频上传期间显示导入进度条、百分比和已上传字节。只有视频项目在尚未生成帧、未处于项目名称编辑状态且未解析中时显示“生成帧”,DICOM 项目不显示生成帧入口;DICOM 批量走 `/api/media/upload/dicom`,前端和后端都会按文件名自然顺序排序 `.dcm` 文件,避免 `10.dcm` 排在 `2.dcm` 前导致切片错位;DICOM 上传期间显示导入进度条、本次有效文件数量和已上传字节,上传完成后轮询解析任务进度直到完成、失败或取消。
|
||||||
5. 生成帧入队:用户在项目库点击“生成帧”,选择目标 FPS 后前端调用 `/api/media/parse`;后端创建 `ProcessingTask` 并投递 Celery,接口支持 `parse_fps`、`max_frames` 和 `target_width` 标准帧序列参数;项目库会继续轮询任务进度,解析成功后重新拉取项目列表和当前项目对象,使后端生成的 `thumbnail_url` 立即显示为项目封面;项目库和模板库的成功/失败短反馈使用非阻塞 `TransientNotice`,会自动消失。
|
5. 生成帧入队:用户在项目库点击“生成帧”,选择目标 FPS 后前端调用 `/api/media/parse`;后端创建 `ProcessingTask` 并投递 Celery,接口支持 `parse_fps`、`max_frames` 和 `target_width` 标准帧序列参数;项目库会继续轮询任务进度,解析成功后重新拉取项目列表和当前项目对象,使后端生成的 `thumbnail_url` 立即显示为项目封面;项目库和模板库的成功/失败短反馈使用非阻塞 `TransientNotice`,会自动消失。
|
||||||
6. worker 执行:Celery worker 用 FFmpeg 优先拆视频帧,失败后用 OpenCV fallback,DICOM 使用 pydicom;worker 下载和读取 DICOM 时也按文件名自然顺序排序;视频/DICOM 解析完成后都按 `frame_%06d.jpg` 连续生成项目帧序列,并记录 `timestamp_ms`、`source_frame_number` 和任务 `frame_sequence` 元数据,后续工作区、时间轴、AI 传播、标注和导出共用同一套帧序列逻辑。
|
6. worker 执行:Celery worker 用 FFmpeg 优先拆视频帧,失败后用 OpenCV fallback,DICOM 使用 pydicom;worker 下载和读取 DICOM 时也按文件名自然顺序排序;视频/DICOM 解析完成后都按 `frame_%06d.jpg` 连续生成项目帧序列,并记录 `timestamp_ms`、`source_frame_number` 和任务 `frame_sequence` 元数据,后续工作区、时间轴、AI 传播、标注和导出共用同一套帧序列逻辑。
|
||||||
@@ -317,9 +317,9 @@ uvicorn main:app --host 0.0.0.0 --port 8000 --reload
|
|||||||
## 安全注意事项
|
## 安全注意事项
|
||||||
|
|
||||||
- FastAPI 已有真实 `users` 表、密码哈希和签名 JWT;默认 `admin / 123456` 只是开发种子用户,生产部署应通过环境变量或数据库改密。
|
- FastAPI 已有真实 `users` 表、密码哈希和签名 JWT;默认 `admin / 123456` 只是开发种子用户,生产部署应通过环境变量或数据库改密。
|
||||||
- 业务路由会校验 Bearer token;项目、帧、标注、任务、Dashboard 和导出按当前用户拥有的项目过滤,模板支持系统模板(`owner_user_id IS NULL`)和用户模板。
|
- 业务路由会校验 Bearer token;项目、帧、标注、任务、Dashboard 和导出使用全员共享项目库,模板支持系统模板(`owner_user_id IS NULL`)和用户模板。
|
||||||
- 角色分为 `admin`、`annotator`、`viewer`:`admin/annotator` 可调用写入类业务接口,`viewer` 只能调用读接口;`/api/admin/*` 仅允许 `admin`。
|
- 角色只分为唯一默认 `admin` 和 `annotator`:`admin/annotator` 可调用写入类业务接口;`/api/admin/*` 仅允许默认 `admin`,包括用户管理、审计日志和演示环境出厂设置。
|
||||||
- 管理员后台支持用户新增、停用/启用、改角色、站内弹窗改密码、站内确认删除无项目用户、查看登录/用户管理审计日志,以及站内二次确认后恢复演示出厂设置;禁止管理员删除、停用或降级自己。
|
- 管理员后台支持新增标注员、停用/启用、站内弹窗改密码、站内确认删除用户、查看登录/用户管理审计日志,以及站内二次确认后恢复演示出厂设置;禁止新增第二个管理员,禁止管理员删除、停用、改名或降级自己。
|
||||||
- JWT 默认开发密钥在 `backend/config.py`,生产部署必须通过 `backend/.env` 覆盖 `JWT_SECRET_KEY`。
|
- JWT 默认开发密钥在 `backend/config.py`,生产部署必须通过 `backend/.env` 覆盖 `JWT_SECRET_KEY`。
|
||||||
- `backend/.env` 被 `.gitignore` 忽略;不要提交真实数据库、MinIO、Redis、模型路径等敏感配置。
|
- `backend/.env` 被 `.gitignore` 忽略;不要提交真实数据库、MinIO、Redis、模型路径等敏感配置。
|
||||||
- `start_services.sh` 中包含本机路径和 sudo 启动逻辑,迁移机器时要审查。
|
- `start_services.sh` 中包含本机路径和 sudo 启动逻辑,迁移机器时要审查。
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from sqlalchemy.orm import Session
|
|||||||
from config import settings
|
from config import settings
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models import Annotation, AuditLog, Frame, Mask, ProcessingTask, Project, Template, User
|
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 routers.auth import SUPPORTED_ROLES, ensure_default_admin, hash_password, normalize_user_role, require_admin, write_audit_log
|
||||||
from schemas import (
|
from schemas import (
|
||||||
AdminUserCreate,
|
AdminUserCreate,
|
||||||
AdminUserUpdate,
|
AdminUserUpdate,
|
||||||
@@ -30,18 +30,22 @@ from services.default_templates import restore_default_templates
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api/admin", tags=["Admin"])
|
router = APIRouter(prefix="/api/admin", tags=["Admin"])
|
||||||
|
|
||||||
VALID_ROLES = {"admin", "annotator", "viewer"}
|
|
||||||
DEMO_RESET_CONFIRMATION = "RESET_DEMO_FACTORY"
|
DEMO_RESET_CONFIRMATION = "RESET_DEMO_FACTORY"
|
||||||
DEMO_PROJECT_NAME = DEMO_DICOM_PROJECT_NAME
|
DEMO_PROJECT_NAME = DEMO_DICOM_PROJECT_NAME
|
||||||
|
|
||||||
|
|
||||||
def _normalize_role(role: str | None) -> str:
|
def _normalize_role(role: str | None) -> str:
|
||||||
normalized = (role or "annotator").strip().lower()
|
normalized = (role or "annotator").strip().lower()
|
||||||
if normalized not in VALID_ROLES:
|
if normalized not in SUPPORTED_ROLES:
|
||||||
raise HTTPException(status_code=400, detail=f"Unsupported role: {role}")
|
raise HTTPException(status_code=400, detail=f"Unsupported role: {role}")
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_non_admin_role(role: str) -> None:
|
||||||
|
if role == "admin":
|
||||||
|
raise HTTPException(status_code=400, detail="Only the default admin account can have admin role")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users", response_model=List[UserOut], summary="List users")
|
@router.get("/users", response_model=List[UserOut], summary="List users")
|
||||||
def list_users(
|
def list_users(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -49,7 +53,8 @@ def list_users(
|
|||||||
) -> List[User]:
|
) -> List[User]:
|
||||||
"""Return all users for the administrator console."""
|
"""Return all users for the administrator console."""
|
||||||
_ = admin_user
|
_ = admin_user
|
||||||
return db.query(User).order_by(User.id).all()
|
users = db.query(User).order_by(User.id).all()
|
||||||
|
return [normalize_user_role(db, user) for user in users]
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@@ -69,10 +74,12 @@ def create_user(
|
|||||||
raise HTTPException(status_code=400, detail="Username is required")
|
raise HTTPException(status_code=400, detail="Username is required")
|
||||||
if len(payload.password) < 6:
|
if len(payload.password) < 6:
|
||||||
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
|
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
|
||||||
|
role = _normalize_role(payload.role)
|
||||||
|
_assert_non_admin_role(role)
|
||||||
user = User(
|
user = User(
|
||||||
username=username,
|
username=username,
|
||||||
password_hash=hash_password(payload.password),
|
password_hash=hash_password(payload.password),
|
||||||
role=_normalize_role(payload.role),
|
role=role,
|
||||||
is_active=1 if payload.is_active else 0,
|
is_active=1 if payload.is_active else 0,
|
||||||
)
|
)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
@@ -104,6 +111,7 @@ def update_user(
|
|||||||
user = db.query(User).filter(User.id == user_id).first()
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
user = normalize_user_role(db, user)
|
||||||
|
|
||||||
updates = payload.model_dump(exclude_unset=True)
|
updates = payload.model_dump(exclude_unset=True)
|
||||||
audit_detail: dict = {"before": {"username": user.username, "role": user.role, "is_active": bool(user.is_active)}}
|
audit_detail: dict = {"before": {"username": user.username, "role": user.role, "is_active": bool(user.is_active)}}
|
||||||
@@ -111,6 +119,8 @@ def update_user(
|
|||||||
username = (updates["username"] or "").strip()
|
username = (updates["username"] or "").strip()
|
||||||
if not username:
|
if not username:
|
||||||
raise HTTPException(status_code=400, detail="Username is required")
|
raise HTTPException(status_code=400, detail="Username is required")
|
||||||
|
if user.role == "admin" and username != settings.default_admin_username:
|
||||||
|
raise HTTPException(status_code=400, detail="Default admin username cannot be changed")
|
||||||
user.username = username
|
user.username = username
|
||||||
if "password" in updates:
|
if "password" in updates:
|
||||||
password = updates["password"] or ""
|
password = updates["password"] or ""
|
||||||
@@ -119,8 +129,11 @@ def update_user(
|
|||||||
user.password_hash = hash_password(password)
|
user.password_hash = hash_password(password)
|
||||||
if "role" in updates:
|
if "role" in updates:
|
||||||
next_role = _normalize_role(updates["role"])
|
next_role = _normalize_role(updates["role"])
|
||||||
if user.id == admin_user.id and next_role != "admin":
|
if user.username == settings.default_admin_username:
|
||||||
raise HTTPException(status_code=400, detail="Cannot remove your own admin role")
|
if next_role != "admin":
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot remove the default admin role")
|
||||||
|
else:
|
||||||
|
_assert_non_admin_role(next_role)
|
||||||
user.role = next_role
|
user.role = next_role
|
||||||
if "is_active" in updates:
|
if "is_active" in updates:
|
||||||
if user.id == admin_user.id and not updates["is_active"]:
|
if user.id == admin_user.id and not updates["is_active"]:
|
||||||
@@ -158,9 +171,9 @@ def delete_user(
|
|||||||
user = db.query(User).filter(User.id == user_id).first()
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
owned_project_count = db.query(Project).filter(Project.owner_user_id == user_id).count()
|
user = normalize_user_role(db, user)
|
||||||
if owned_project_count:
|
if user.role == "admin":
|
||||||
raise HTTPException(status_code=409, detail="User owns projects; deactivate or migrate projects first")
|
raise HTTPException(status_code=400, detail="Cannot delete the default admin account")
|
||||||
username = user.username
|
username = user.username
|
||||||
db.delete(user)
|
db.delete(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -45,21 +45,20 @@ GT_IMPORT_CONTOUR_EPSILON_RATIO = 0.00075
|
|||||||
GT_IMPORT_MIN_CONTOUR_EPSILON = 0.35
|
GT_IMPORT_MIN_CONTOUR_EPSILON = 0.35
|
||||||
|
|
||||||
|
|
||||||
def _owned_project_or_404(project_id: int, db: Session, current_user: User) -> Project:
|
def _shared_project_or_404(project_id: int, db: Session, current_user: User) -> Project:
|
||||||
project = db.query(Project).filter(
|
_ = current_user
|
||||||
Project.id == project_id,
|
project = db.query(Project).filter(Project.id == project_id).first()
|
||||||
Project.owner_user_id == current_user.id,
|
|
||||||
).first()
|
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
return project
|
return project
|
||||||
|
|
||||||
|
|
||||||
def _owned_frame_or_404(frame_id: int, db: Session, current_user: User, project_id: int | None = None) -> Frame:
|
def _shared_frame_or_404(frame_id: int, db: Session, current_user: User, project_id: int | None = None) -> Frame:
|
||||||
|
_ = current_user
|
||||||
query = (
|
query = (
|
||||||
db.query(Frame)
|
db.query(Frame)
|
||||||
.join(Project, Project.id == Frame.project_id)
|
.join(Project, Project.id == Frame.project_id)
|
||||||
.filter(Frame.id == frame_id, Project.owner_user_id == current_user.id)
|
.filter(Frame.id == frame_id)
|
||||||
)
|
)
|
||||||
if project_id is not None:
|
if project_id is not None:
|
||||||
query = query.filter(Frame.project_id == project_id)
|
query = query.filter(Frame.project_id == project_id)
|
||||||
@@ -480,7 +479,7 @@ def predict(
|
|||||||
- **interactive**: `prompt_data` is `{ "box": [...], "points": [[x, y]], "labels": [1, 0] }`.
|
- **interactive**: `prompt_data` is `{ "box": [...], "points": [[x, y]], "labels": [1, 0] }`.
|
||||||
- **semantic**: disabled in the current SAM 2.1 point/box product flow.
|
- **semantic**: disabled in the current SAM 2.1 point/box product flow.
|
||||||
"""
|
"""
|
||||||
frame = _owned_frame_or_404(payload.image_id, db, current_user)
|
frame = _shared_frame_or_404(payload.image_id, db, current_user)
|
||||||
|
|
||||||
image = _load_frame_image(frame)
|
image = _load_frame_image(frame)
|
||||||
prompt_type = payload.prompt_type.lower()
|
prompt_type = payload.prompt_type.lower()
|
||||||
@@ -649,7 +648,7 @@ def analyze_mask(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""Return backend-computed mask properties for the frontend inspector."""
|
"""Return backend-computed mask properties for the frontend inspector."""
|
||||||
if payload.frame_id is not None:
|
if payload.frame_id is not None:
|
||||||
_owned_frame_or_404(payload.frame_id, db, current_user)
|
_shared_frame_or_404(payload.frame_id, db, current_user)
|
||||||
|
|
||||||
mask_data = payload.mask_data or {}
|
mask_data = payload.mask_data or {}
|
||||||
polygons = mask_data.get("polygons") or []
|
polygons = mask_data.get("polygons") or []
|
||||||
@@ -705,7 +704,7 @@ def smooth_mask(
|
|||||||
to the current mask, then save through the normal annotation endpoint.
|
to the current mask, then save through the normal annotation endpoint.
|
||||||
"""
|
"""
|
||||||
if payload.frame_id is not None:
|
if payload.frame_id is not None:
|
||||||
_owned_frame_or_404(payload.frame_id, db, current_user)
|
_shared_frame_or_404(payload.frame_id, db, current_user)
|
||||||
|
|
||||||
polygons = payload.mask_data.get("polygons") or []
|
polygons = payload.mask_data.get("polygons") or []
|
||||||
valid_polygons = _normalize_polygons(polygons)
|
valid_polygons = _normalize_polygons(polygons)
|
||||||
@@ -751,8 +750,8 @@ def propagate(
|
|||||||
raise HTTPException(status_code=400, detail="direction must be forward, backward, or both")
|
raise HTTPException(status_code=400, detail="direction must be forward, backward, or both")
|
||||||
max_frames = max(1, min(int(payload.max_frames or 30), 500))
|
max_frames = max(1, min(int(payload.max_frames or 30), 500))
|
||||||
|
|
||||||
_owned_project_or_404(payload.project_id, db, current_user)
|
_shared_project_or_404(payload.project_id, db, current_user)
|
||||||
source_frame = _owned_frame_or_404(payload.frame_id, db, current_user, payload.project_id)
|
source_frame = _shared_frame_or_404(payload.frame_id, db, current_user, payload.project_id)
|
||||||
|
|
||||||
seed = payload.seed.model_dump(exclude_none=True)
|
seed = payload.seed.model_dump(exclude_none=True)
|
||||||
polygons = seed.get("polygons") or []
|
polygons = seed.get("polygons") or []
|
||||||
@@ -881,8 +880,8 @@ def queue_propagate_task(
|
|||||||
current_user: User = Depends(require_editor),
|
current_user: User = Depends(require_editor),
|
||||||
) -> ProcessingTaskOut:
|
) -> ProcessingTaskOut:
|
||||||
"""Queue multiple seed/direction propagation steps as one background task."""
|
"""Queue multiple seed/direction propagation steps as one background task."""
|
||||||
_owned_project_or_404(payload.project_id, db, current_user)
|
_shared_project_or_404(payload.project_id, db, current_user)
|
||||||
source_frame = _owned_frame_or_404(payload.frame_id, db, current_user, payload.project_id)
|
source_frame = _shared_frame_or_404(payload.frame_id, db, current_user, payload.project_id)
|
||||||
|
|
||||||
if not payload.steps:
|
if not payload.steps:
|
||||||
raise HTTPException(status_code=400, detail="Propagation task requires at least one step")
|
raise HTTPException(status_code=400, detail="Propagation task requires at least one step")
|
||||||
@@ -936,7 +935,7 @@ def auto_segment(
|
|||||||
current_user: User = Depends(require_editor),
|
current_user: User = Depends(require_editor),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Run automatic mask generation on a frame using a grid of point prompts."""
|
"""Run automatic mask generation on a frame using a grid of point prompts."""
|
||||||
frame = _owned_frame_or_404(image_id, db, current_user)
|
frame = _shared_frame_or_404(image_id, db, current_user)
|
||||||
|
|
||||||
image = _load_frame_image(frame)
|
image = _load_frame_image(frame)
|
||||||
try:
|
try:
|
||||||
@@ -959,10 +958,10 @@ def save_annotation(
|
|||||||
current_user: User = Depends(require_editor),
|
current_user: User = Depends(require_editor),
|
||||||
) -> Annotation:
|
) -> Annotation:
|
||||||
"""Persist an annotation (mask, points, bbox) into the database."""
|
"""Persist an annotation (mask, points, bbox) into the database."""
|
||||||
_owned_project_or_404(payload.project_id, db, current_user)
|
_shared_project_or_404(payload.project_id, db, current_user)
|
||||||
|
|
||||||
if payload.frame_id:
|
if payload.frame_id:
|
||||||
_owned_frame_or_404(payload.frame_id, db, current_user, payload.project_id)
|
_shared_frame_or_404(payload.frame_id, db, current_user, payload.project_id)
|
||||||
if payload.template_id:
|
if payload.template_id:
|
||||||
_visible_template_or_404(payload.template_id, db, current_user)
|
_visible_template_or_404(payload.template_id, db, current_user)
|
||||||
|
|
||||||
@@ -998,8 +997,8 @@ async def import_gt_mask(
|
|||||||
the frontend an editable point-region representation instead of a static
|
the frontend an editable point-region representation instead of a static
|
||||||
bitmap layer.
|
bitmap layer.
|
||||||
"""
|
"""
|
||||||
_owned_project_or_404(project_id, db, current_user)
|
_shared_project_or_404(project_id, db, current_user)
|
||||||
frame = _owned_frame_or_404(frame_id, db, current_user, project_id)
|
frame = _shared_frame_or_404(frame_id, db, current_user, project_id)
|
||||||
|
|
||||||
if unknown_color_policy not in {"discard", "undefined"}:
|
if unknown_color_policy not in {"discard", "undefined"}:
|
||||||
raise HTTPException(status_code=400, detail="unknown_color_policy must be discard or undefined")
|
raise HTTPException(status_code=400, detail="unknown_color_policy must be discard or undefined")
|
||||||
@@ -1143,11 +1142,11 @@ def list_annotations(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
) -> List[Annotation]:
|
) -> List[Annotation]:
|
||||||
"""Return persisted annotations for a project, optionally scoped to one frame."""
|
"""Return persisted annotations for a project, optionally scoped to one frame."""
|
||||||
_owned_project_or_404(project_id, db, current_user)
|
_shared_project_or_404(project_id, db, current_user)
|
||||||
|
|
||||||
query = db.query(Annotation).filter(Annotation.project_id == project_id)
|
query = db.query(Annotation).filter(Annotation.project_id == project_id)
|
||||||
if frame_id is not None:
|
if frame_id is not None:
|
||||||
_owned_frame_or_404(frame_id, db, current_user, project_id)
|
_shared_frame_or_404(frame_id, db, current_user, project_id)
|
||||||
query = query.filter(Annotation.frame_id == frame_id)
|
query = query.filter(Annotation.frame_id == frame_id)
|
||||||
return query.order_by(Annotation.id).all()
|
return query.order_by(Annotation.id).all()
|
||||||
|
|
||||||
@@ -1167,7 +1166,7 @@ def update_annotation(
|
|||||||
annotation = (
|
annotation = (
|
||||||
db.query(Annotation)
|
db.query(Annotation)
|
||||||
.join(Project, Project.id == Annotation.project_id)
|
.join(Project, Project.id == Annotation.project_id)
|
||||||
.filter(Annotation.id == annotation_id, Project.owner_user_id == current_user.id)
|
.filter(Annotation.id == annotation_id)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if not annotation:
|
if not annotation:
|
||||||
@@ -1200,7 +1199,7 @@ def delete_annotation(
|
|||||||
annotation = (
|
annotation = (
|
||||||
db.query(Annotation)
|
db.query(Annotation)
|
||||||
.join(Project, Project.id == Annotation.project_id)
|
.join(Project, Project.id == Annotation.project_id)
|
||||||
.filter(Annotation.id == annotation_id, Project.owner_user_id == current_user.id)
|
.filter(Annotation.id == annotation_id)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if not annotation:
|
if not annotation:
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from schemas import LoginResponse, UserOut
|
|||||||
router = APIRouter(prefix="/api/auth", tags=["Auth"])
|
router = APIRouter(prefix="/api/auth", tags=["Auth"])
|
||||||
password_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
password_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
||||||
bearer_scheme = HTTPBearer(auto_error=False)
|
bearer_scheme = HTTPBearer(auto_error=False)
|
||||||
|
SUPPORTED_ROLES = {"admin", "annotator"}
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
@@ -50,9 +51,26 @@ def create_access_token(user: User, expires_delta: timedelta | None = None) -> s
|
|||||||
|
|
||||||
|
|
||||||
def ensure_default_admin(db: Session) -> User:
|
def ensure_default_admin(db: Session) -> User:
|
||||||
"""Create the default development admin if the user table is empty."""
|
"""Create and enforce the single default administrator account."""
|
||||||
existing = db.query(User).filter(User.username == settings.default_admin_username).first()
|
existing = db.query(User).filter(User.username == settings.default_admin_username).first()
|
||||||
if existing:
|
if existing:
|
||||||
|
changed = False
|
||||||
|
if existing.role != "admin":
|
||||||
|
existing.role = "admin"
|
||||||
|
changed = True
|
||||||
|
if not existing.is_active:
|
||||||
|
existing.is_active = 1
|
||||||
|
changed = True
|
||||||
|
extra_admins = db.query(User).filter(
|
||||||
|
User.role == "admin",
|
||||||
|
User.id != existing.id,
|
||||||
|
).all()
|
||||||
|
for user in extra_admins:
|
||||||
|
user.role = "annotator"
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
db.commit()
|
||||||
|
db.refresh(existing)
|
||||||
return existing
|
return existing
|
||||||
user = User(
|
user = User(
|
||||||
username=settings.default_admin_username,
|
username=settings.default_admin_username,
|
||||||
@@ -63,6 +81,31 @@ def ensure_default_admin(db: Session) -> User:
|
|||||||
db.add(user)
|
db.add(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(user)
|
db.refresh(user)
|
||||||
|
extra_admins = db.query(User).filter(
|
||||||
|
User.role == "admin",
|
||||||
|
User.id != user.id,
|
||||||
|
).all()
|
||||||
|
if extra_admins:
|
||||||
|
for extra_user in extra_admins:
|
||||||
|
extra_user.role = "annotator"
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_user_role(db: Session, user: User) -> User:
|
||||||
|
"""Keep legacy accounts within the current two-role policy."""
|
||||||
|
desired_role = "admin" if user.username == settings.default_admin_username else "annotator"
|
||||||
|
changed = False
|
||||||
|
if user.role != desired_role:
|
||||||
|
user.role = desired_role
|
||||||
|
changed = True
|
||||||
|
if user.username == settings.default_admin_username and not user.is_active:
|
||||||
|
user.is_active = 1
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
@@ -92,6 +135,8 @@ def get_current_user(
|
|||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
user = db.query(User).filter(User.id == user_id).first()
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if user:
|
||||||
|
user = normalize_user_role(db, user)
|
||||||
if not user or not user.is_active:
|
if not user or not user.is_active:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
@@ -110,7 +155,7 @@ def require_admin(current_user: User = Depends(get_current_user)) -> User:
|
|||||||
|
|
||||||
def require_editor(current_user: User = Depends(get_current_user)) -> User:
|
def require_editor(current_user: User = Depends(get_current_user)) -> User:
|
||||||
"""Require a user role that can modify segmentation data."""
|
"""Require a user role that can modify segmentation data."""
|
||||||
if current_user.role not in {"admin", "annotator"}:
|
if current_user.role not in SUPPORTED_ROLES:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Edit permission required")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Edit permission required")
|
||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
@@ -143,6 +188,8 @@ def login(payload: LoginRequest, db: Session = Depends(get_db)) -> dict:
|
|||||||
"""Authenticate a user and return a signed JWT."""
|
"""Authenticate a user and return a signed JWT."""
|
||||||
ensure_default_admin(db)
|
ensure_default_admin(db)
|
||||||
user = db.query(User).filter(User.username == payload.username).first()
|
user = db.query(User).filter(User.username == payload.username).first()
|
||||||
|
if user:
|
||||||
|
user = normalize_user_role(db, user)
|
||||||
if not user or not user.is_active or not verify_password(payload.password, user.password_hash):
|
if not user or not user.is_active or not verify_password(payload.password, user.password_hash):
|
||||||
write_audit_log(
|
write_audit_log(
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -58,12 +58,12 @@ def get_dashboard_overview(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Return live dashboard data derived from persisted backend records."""
|
"""Return live dashboard data derived from persisted backend records."""
|
||||||
owned_project_ids_query = db.query(Project.id).filter(Project.owner_user_id == current_user.id)
|
shared_project_ids_query = db.query(Project.id)
|
||||||
project_count = db.query(func.count(Project.id)).filter(Project.owner_user_id == current_user.id).scalar() or 0
|
project_count = db.query(func.count(Project.id)).scalar() or 0
|
||||||
frame_count = db.query(func.count(Frame.id)).filter(Frame.project_id.in_(owned_project_ids_query)).scalar() or 0
|
frame_count = db.query(func.count(Frame.id)).filter(Frame.project_id.in_(shared_project_ids_query)).scalar() or 0
|
||||||
annotation_count = (
|
annotation_count = (
|
||||||
db.query(func.count(Annotation.id))
|
db.query(func.count(Annotation.id))
|
||||||
.filter(Annotation.project_id.in_(owned_project_ids_query))
|
.filter(Annotation.project_id.in_(shared_project_ids_query))
|
||||||
.scalar()
|
.scalar()
|
||||||
or 0
|
or 0
|
||||||
)
|
)
|
||||||
@@ -76,7 +76,6 @@ def get_dashboard_overview(
|
|||||||
active_task_count = (
|
active_task_count = (
|
||||||
db.query(func.count(ProcessingTask.id))
|
db.query(func.count(ProcessingTask.id))
|
||||||
.outerjoin(Project, Project.id == ProcessingTask.project_id)
|
.outerjoin(Project, Project.id == ProcessingTask.project_id)
|
||||||
.filter((ProcessingTask.project_id.is_(None)) | (Project.owner_user_id == current_user.id))
|
|
||||||
.filter(ProcessingTask.status.in_(ACTIVE_TASK_STATUSES))
|
.filter(ProcessingTask.status.in_(ACTIVE_TASK_STATUSES))
|
||||||
.scalar()
|
.scalar()
|
||||||
or 0
|
or 0
|
||||||
@@ -84,14 +83,12 @@ def get_dashboard_overview(
|
|||||||
|
|
||||||
projects = (
|
projects = (
|
||||||
db.query(Project)
|
db.query(Project)
|
||||||
.filter(Project.owner_user_id == current_user.id)
|
|
||||||
.order_by(Project.updated_at.desc())
|
.order_by(Project.updated_at.desc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
recent_tasks = (
|
recent_tasks = (
|
||||||
db.query(ProcessingTask)
|
db.query(ProcessingTask)
|
||||||
.outerjoin(Project, Project.id == ProcessingTask.project_id)
|
.outerjoin(Project, Project.id == ProcessingTask.project_id)
|
||||||
.filter((ProcessingTask.project_id.is_(None)) | (Project.owner_user_id == current_user.id))
|
|
||||||
.order_by(ProcessingTask.created_at.desc())
|
.order_by(ProcessingTask.created_at.desc())
|
||||||
.limit(50)
|
.limit(50)
|
||||||
.all()
|
.all()
|
||||||
@@ -120,7 +117,7 @@ def get_dashboard_overview(
|
|||||||
|
|
||||||
recent_annotations = (
|
recent_annotations = (
|
||||||
db.query(Annotation)
|
db.query(Annotation)
|
||||||
.filter(Annotation.project_id.in_(owned_project_ids_query))
|
.filter(Annotation.project_id.in_(shared_project_ids_query))
|
||||||
.order_by(Annotation.updated_at.desc())
|
.order_by(Annotation.updated_at.desc())
|
||||||
.limit(10)
|
.limit(10)
|
||||||
.all()
|
.all()
|
||||||
|
|||||||
@@ -206,10 +206,8 @@ def _frame_image_extension(frame: Frame) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _project_or_404(project_id: int, db: Session, current_user: User) -> Project:
|
def _project_or_404(project_id: int, db: Session, current_user: User) -> Project:
|
||||||
project = db.query(Project).filter(
|
_ = current_user
|
||||||
Project.id == project_id,
|
project = db.query(Project).filter(Project.id == project_id).first()
|
||||||
Project.owner_user_id == current_user.id,
|
|
||||||
).first()
|
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
return project
|
return project
|
||||||
|
|||||||
@@ -72,10 +72,7 @@ async def upload_media(
|
|||||||
file_url = get_presigned_url(object_name, expires=3600)
|
file_url = get_presigned_url(object_name, expires=3600)
|
||||||
|
|
||||||
if project_id:
|
if project_id:
|
||||||
project = db.query(Project).filter(
|
project = db.query(Project).filter(Project.id == project_id).first()
|
||||||
Project.id == project_id,
|
|
||||||
Project.owner_user_id == current_user.id,
|
|
||||||
).first()
|
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
project.video_path = object_name
|
project.video_path = object_name
|
||||||
@@ -141,10 +138,7 @@ async def upload_dicom_batch(
|
|||||||
uploaded = []
|
uploaded = []
|
||||||
|
|
||||||
if project_id:
|
if project_id:
|
||||||
project = db.query(Project).filter(
|
project = db.query(Project).filter(Project.id == project_id).first()
|
||||||
Project.id == project_id,
|
|
||||||
Project.owner_user_id == current_user.id,
|
|
||||||
).first()
|
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
else:
|
else:
|
||||||
@@ -202,10 +196,7 @@ def parse_media(
|
|||||||
The Celery worker performs the heavy FFmpeg/OpenCV/pydicom work and
|
The Celery worker performs the heavy FFmpeg/OpenCV/pydicom work and
|
||||||
updates the persisted task record as it progresses.
|
updates the persisted task record as it progresses.
|
||||||
"""
|
"""
|
||||||
project = db.query(Project).filter(
|
project = db.query(Project).filter(Project.id == project_id).first()
|
||||||
Project.id == project_id,
|
|
||||||
Project.owner_user_id == current_user.id,
|
|
||||||
).first()
|
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/api/projects", tags=["Projects"])
|
router = APIRouter(prefix="/api/projects", tags=["Projects"])
|
||||||
|
|
||||||
|
|
||||||
def _next_project_copy_name(db: Session, owner_user_id: int, source_name: str) -> str:
|
def _next_project_copy_name(db: Session, source_name: str) -> str:
|
||||||
base_name = f"{source_name} 副本"
|
base_name = f"{source_name} 副本"
|
||||||
existing_names = {
|
existing_names = {
|
||||||
row[0]
|
row[0]
|
||||||
for row in db.query(Project.name)
|
for row in db.query(Project.name)
|
||||||
.filter(Project.owner_user_id == owner_user_id, Project.name.like(f"{base_name}%"))
|
.filter(Project.name.like(f"{base_name}%"))
|
||||||
.all()
|
.all()
|
||||||
}
|
}
|
||||||
if base_name not in existing_names:
|
if base_name not in existing_names:
|
||||||
@@ -76,7 +76,6 @@ def list_projects(
|
|||||||
"""Retrieve a paginated list of projects."""
|
"""Retrieve a paginated list of projects."""
|
||||||
projects = (
|
projects = (
|
||||||
db.query(Project)
|
db.query(Project)
|
||||||
.filter(Project.owner_user_id == current_user.id)
|
|
||||||
.offset(skip)
|
.offset(skip)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.all()
|
.all()
|
||||||
@@ -97,10 +96,7 @@ def get_project(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
) -> Project:
|
) -> Project:
|
||||||
"""Retrieve a project by its ID."""
|
"""Retrieve a project by its ID."""
|
||||||
project = db.query(Project).filter(
|
project = db.query(Project).filter(Project.id == project_id).first()
|
||||||
Project.id == project_id,
|
|
||||||
Project.owner_user_id == current_user.id,
|
|
||||||
).first()
|
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
return _prepare_project_response(project)
|
return _prepare_project_response(project)
|
||||||
@@ -119,16 +115,13 @@ def copy_project(
|
|||||||
current_user: User = Depends(require_editor),
|
current_user: User = Depends(require_editor),
|
||||||
) -> Project:
|
) -> Project:
|
||||||
"""Copy a project. Reset copies media/frame sequence; full also copies annotations and mask metadata."""
|
"""Copy a project. Reset copies media/frame sequence; full also copies annotations and mask metadata."""
|
||||||
source = db.query(Project).filter(
|
source = db.query(Project).filter(Project.id == project_id).first()
|
||||||
Project.id == project_id,
|
|
||||||
Project.owner_user_id == current_user.id,
|
|
||||||
).first()
|
|
||||||
if not source:
|
if not source:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
next_name = (payload.name or "").strip() if payload.name is not None else ""
|
next_name = (payload.name or "").strip() if payload.name is not None else ""
|
||||||
if not next_name:
|
if not next_name:
|
||||||
next_name = _next_project_copy_name(db, current_user.id, source.name)
|
next_name = _next_project_copy_name(db, source.name)
|
||||||
|
|
||||||
copied = Project(
|
copied = Project(
|
||||||
name=next_name,
|
name=next_name,
|
||||||
@@ -196,10 +189,7 @@ def update_project(
|
|||||||
current_user: User = Depends(require_editor),
|
current_user: User = Depends(require_editor),
|
||||||
) -> Project:
|
) -> Project:
|
||||||
"""Update project fields partially."""
|
"""Update project fields partially."""
|
||||||
project = db.query(Project).filter(
|
project = db.query(Project).filter(Project.id == project_id).first()
|
||||||
Project.id == project_id,
|
|
||||||
Project.owner_user_id == current_user.id,
|
|
||||||
).first()
|
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
@@ -227,10 +217,7 @@ def delete_project(
|
|||||||
current_user: User = Depends(require_editor),
|
current_user: User = Depends(require_editor),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Delete a project and all related frames and annotations."""
|
"""Delete a project and all related frames and annotations."""
|
||||||
project = db.query(Project).filter(
|
project = db.query(Project).filter(Project.id == project_id).first()
|
||||||
Project.id == project_id,
|
|
||||||
Project.owner_user_id == current_user.id,
|
|
||||||
).first()
|
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
@@ -255,10 +242,7 @@ def create_frame(
|
|||||||
current_user: User = Depends(require_editor),
|
current_user: User = Depends(require_editor),
|
||||||
) -> Frame:
|
) -> Frame:
|
||||||
"""Register a new frame under a project."""
|
"""Register a new frame under a project."""
|
||||||
project = db.query(Project).filter(
|
project = db.query(Project).filter(Project.id == project_id).first()
|
||||||
Project.id == project_id,
|
|
||||||
Project.owner_user_id == current_user.id,
|
|
||||||
).first()
|
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
@@ -282,10 +266,7 @@ def list_frames(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
) -> List[Frame]:
|
) -> List[Frame]:
|
||||||
"""Retrieve all frames belonging to a project."""
|
"""Retrieve all frames belonging to a project."""
|
||||||
project = db.query(Project).filter(
|
project = db.query(Project).filter(Project.id == project_id).first()
|
||||||
Project.id == project_id,
|
|
||||||
Project.owner_user_id == current_user.id,
|
|
||||||
).first()
|
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
@@ -320,7 +301,6 @@ def get_frame(
|
|||||||
.filter(
|
.filter(
|
||||||
Frame.project_id == project_id,
|
Frame.project_id == project_id,
|
||||||
Frame.id == frame_id,
|
Frame.id == frame_id,
|
||||||
Project.owner_user_id == current_user.id,
|
|
||||||
)
|
)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -33,13 +33,11 @@ def _now() -> datetime:
|
|||||||
|
|
||||||
|
|
||||||
def _get_task_or_404(task_id: int, db: Session, current_user: User) -> ProcessingTask:
|
def _get_task_or_404(task_id: int, db: Session, current_user: User) -> ProcessingTask:
|
||||||
|
_ = current_user
|
||||||
task = (
|
task = (
|
||||||
db.query(ProcessingTask)
|
db.query(ProcessingTask)
|
||||||
.outerjoin(Project, Project.id == ProcessingTask.project_id)
|
.outerjoin(Project, Project.id == ProcessingTask.project_id)
|
||||||
.filter(
|
.filter(ProcessingTask.id == task_id)
|
||||||
ProcessingTask.id == task_id,
|
|
||||||
(ProcessingTask.project_id.is_(None)) | (Project.owner_user_id == current_user.id),
|
|
||||||
)
|
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if not task:
|
if not task:
|
||||||
@@ -60,9 +58,8 @@ def list_tasks(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
) -> List[ProcessingTask]:
|
) -> List[ProcessingTask]:
|
||||||
"""Return recent background processing tasks."""
|
"""Return recent background processing tasks."""
|
||||||
query = db.query(ProcessingTask).outerjoin(Project, Project.id == ProcessingTask.project_id).filter(
|
_ = current_user
|
||||||
(ProcessingTask.project_id.is_(None)) | (Project.owner_user_id == current_user.id)
|
query = db.query(ProcessingTask).outerjoin(Project, Project.id == ProcessingTask.project_id)
|
||||||
)
|
|
||||||
if project_id is not None:
|
if project_id is not None:
|
||||||
query = query.filter(ProcessingTask.project_id == project_id)
|
query = query.filter(ProcessingTask.project_id == project_id)
|
||||||
if status is not None:
|
if status is not None:
|
||||||
@@ -130,10 +127,7 @@ def retry_task(
|
|||||||
if previous.project_id is None:
|
if previous.project_id is None:
|
||||||
raise HTTPException(status_code=400, detail="Task has no project_id")
|
raise HTTPException(status_code=400, detail="Task has no project_id")
|
||||||
|
|
||||||
project = db.query(Project).filter(
|
project = db.query(Project).filter(Project.id == previous.project_id).first()
|
||||||
Project.id == previous.project_id,
|
|
||||||
Project.owner_user_id == current_user.id,
|
|
||||||
).first()
|
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
is_propagation_task = previous.task_type == "propagate_masks"
|
is_propagation_task = previous.task_type == "propagate_masks"
|
||||||
|
|||||||
@@ -12,14 +12,14 @@ def test_admin_user_management_and_audit_logs(client, db_session):
|
|||||||
})
|
})
|
||||||
assert created.status_code == 201
|
assert created.status_code == 201
|
||||||
user_id = created.json()["id"]
|
user_id = created.json()["id"]
|
||||||
|
assert created.json()["role"] == "annotator"
|
||||||
|
|
||||||
updated = client.patch(f"/api/admin/users/{user_id}", json={
|
updated = client.patch(f"/api/admin/users/{user_id}", json={
|
||||||
"role": "viewer",
|
|
||||||
"password": "newsecret",
|
"password": "newsecret",
|
||||||
"is_active": False,
|
"is_active": False,
|
||||||
})
|
})
|
||||||
assert updated.status_code == 200
|
assert updated.status_code == 200
|
||||||
assert updated.json()["role"] == "viewer"
|
assert updated.json()["role"] == "annotator"
|
||||||
assert updated.json()["is_active"] == 0
|
assert updated.json()["is_active"] == 0
|
||||||
|
|
||||||
users = client.get("/api/admin/users")
|
users = client.get("/api/admin/users")
|
||||||
@@ -37,8 +37,41 @@ def test_admin_user_management_and_audit_logs(client, db_session):
|
|||||||
assert "admin.user_deleted" in actions
|
assert "admin.user_deleted" in actions
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_default_admin_role_is_supported(client, db_session):
|
||||||
|
extra_admin = client.post("/api/admin/users", json={
|
||||||
|
"username": "chief",
|
||||||
|
"password": "secret123",
|
||||||
|
"role": "admin",
|
||||||
|
"is_active": True,
|
||||||
|
})
|
||||||
|
assert extra_admin.status_code == 400
|
||||||
|
|
||||||
|
viewer = client.post("/api/admin/users", json={
|
||||||
|
"username": "observer",
|
||||||
|
"password": "secret123",
|
||||||
|
"role": "viewer",
|
||||||
|
"is_active": True,
|
||||||
|
})
|
||||||
|
assert viewer.status_code == 400
|
||||||
|
|
||||||
|
created = client.post("/api/admin/users", json={
|
||||||
|
"username": "doctor",
|
||||||
|
"password": "secret123",
|
||||||
|
"is_active": True,
|
||||||
|
})
|
||||||
|
assert created.status_code == 201
|
||||||
|
user_id = created.json()["id"]
|
||||||
|
assert created.json()["role"] == "annotator"
|
||||||
|
assert client.patch(f"/api/admin/users/{user_id}", json={"role": "admin"}).status_code == 400
|
||||||
|
assert client.patch(f"/api/admin/users/{user_id}", json={"role": "viewer"}).status_code == 400
|
||||||
|
|
||||||
|
admin_id = client.get("/api/auth/me").json()["id"]
|
||||||
|
assert client.patch(f"/api/admin/users/{admin_id}", json={"role": "annotator"}).status_code == 400
|
||||||
|
assert client.patch(f"/api/admin/users/{admin_id}", json={"username": "chief"}).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
def test_admin_routes_require_admin_role(client, db_session):
|
def test_admin_routes_require_admin_role(client, db_session):
|
||||||
user = User(username="viewer", password_hash=hash_password("secret123"), role="viewer", is_active=1)
|
user = User(username="doctor", password_hash=hash_password("secret123"), role="annotator", is_active=1)
|
||||||
db_session.add(user)
|
db_session.add(user)
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
db_session.refresh(user)
|
db_session.refresh(user)
|
||||||
@@ -51,7 +84,7 @@ def test_admin_routes_require_admin_role(client, db_session):
|
|||||||
client.headers.update({"Authorization": original_auth})
|
client.headers.update({"Authorization": original_auth})
|
||||||
|
|
||||||
|
|
||||||
def test_viewer_role_is_read_only_for_business_mutations(client, db_session):
|
def test_legacy_viewer_role_is_promoted_to_annotator(client, db_session):
|
||||||
project = client.post("/api/projects", json={"name": "Readonly Check"}).json()
|
project = client.post("/api/projects", json={"name": "Readonly Check"}).json()
|
||||||
user = User(username="readonly", password_hash=hash_password("secret123"), role="viewer", is_active=1)
|
user = User(username="readonly", password_hash=hash_password("secret123"), role="viewer", is_active=1)
|
||||||
db_session.add(user)
|
db_session.add(user)
|
||||||
@@ -61,14 +94,14 @@ def test_viewer_role_is_read_only_for_business_mutations(client, db_session):
|
|||||||
client.headers.update({"Authorization": f"Bearer {create_access_token(user)}"})
|
client.headers.update({"Authorization": f"Bearer {create_access_token(user)}"})
|
||||||
try:
|
try:
|
||||||
assert client.get("/api/projects").status_code == 200
|
assert client.get("/api/projects").status_code == 200
|
||||||
assert client.post("/api/projects", json={"name": "Nope"}).status_code == 403
|
assert client.post("/api/projects", json={"name": "Annotator Project"}).status_code == 201
|
||||||
assert client.patch(f"/api/projects/{project['id']}", json={"name": "Nope"}).status_code == 403
|
assert client.patch(f"/api/projects/{project['id']}", json={"name": "Shared Edit"}).status_code == 200
|
||||||
assert client.post("/api/ai/annotate", json={"project_id": project["id"]}).status_code == 403
|
assert client.get("/api/auth/me").json()["role"] == "annotator"
|
||||||
finally:
|
finally:
|
||||||
client.headers.update({"Authorization": original_auth})
|
client.headers.update({"Authorization": original_auth})
|
||||||
|
|
||||||
|
|
||||||
def test_admin_cannot_delete_self_or_user_with_projects(client, db_session):
|
def test_admin_cannot_delete_self_but_can_delete_project_author(client, db_session):
|
||||||
me = client.get("/api/auth/me").json()
|
me = client.get("/api/auth/me").json()
|
||||||
assert client.delete(f"/api/admin/users/{me['id']}").status_code == 400
|
assert client.delete(f"/api/admin/users/{me['id']}").status_code == 400
|
||||||
|
|
||||||
@@ -80,7 +113,8 @@ def test_admin_cannot_delete_self_or_user_with_projects(client, db_session):
|
|||||||
db_session.commit()
|
db_session.commit()
|
||||||
|
|
||||||
response = client.delete(f"/api/admin/users/{user.id}")
|
response = client.delete(f"/api/admin/users/{user.id}")
|
||||||
assert response.status_code == 409
|
assert response.status_code == 204
|
||||||
|
assert db_session.query(Project).filter(Project.name == "Owned").count() == 1
|
||||||
|
|
||||||
|
|
||||||
def test_demo_factory_reset_leaves_admin_and_parsed_demo_dicom(client, db_session, monkeypatch, tmp_path):
|
def test_demo_factory_reset_leaves_admin_and_parsed_demo_dicom(client, db_session, monkeypatch, tmp_path):
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ def test_project_and_frame_404s(client):
|
|||||||
assert client.get("/api/projects/999/frames/1").status_code == 404
|
assert client.get("/api/projects/999/frames/1").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
def test_projects_are_scoped_to_authenticated_owner(client, db_session):
|
def test_projects_are_shared_between_authenticated_users(client, db_session):
|
||||||
owner_project = client.post("/api/projects", json={"name": "Owner Project"}).json()
|
owner_project = client.post("/api/projects", json={"name": "Owner Project"}).json()
|
||||||
other_user = User(
|
other_user = User(
|
||||||
username="other",
|
username="other",
|
||||||
@@ -203,14 +203,19 @@ def test_projects_are_scoped_to_authenticated_owner(client, db_session):
|
|||||||
db_session.refresh(other_project)
|
db_session.refresh(other_project)
|
||||||
|
|
||||||
listing = client.get("/api/projects")
|
listing = client.get("/api/projects")
|
||||||
assert [project["id"] for project in listing.json()] == [owner_project["id"]]
|
assert {project["id"] for project in listing.json()} == {owner_project["id"], other_project.id}
|
||||||
assert client.get(f"/api/projects/{other_project.id}").status_code == 404
|
assert client.get(f"/api/projects/{other_project.id}").status_code == 200
|
||||||
|
|
||||||
original_auth = client.headers["Authorization"]
|
original_auth = client.headers["Authorization"]
|
||||||
client.headers.update({"Authorization": f"Bearer {create_access_token(other_user)}"})
|
client.headers.update({"Authorization": f"Bearer {create_access_token(other_user)}"})
|
||||||
try:
|
try:
|
||||||
other_listing = client.get("/api/projects")
|
other_listing = client.get("/api/projects")
|
||||||
assert [project["id"] for project in other_listing.json()] == [other_project.id]
|
assert {project["id"] for project in other_listing.json()} == {owner_project["id"], other_project.id}
|
||||||
assert client.get(f"/api/projects/{owner_project['id']}").status_code == 404
|
assert client.get(f"/api/projects/{owner_project['id']}").status_code == 200
|
||||||
|
renamed = client.patch(f"/api/projects/{owner_project['id']}", json={"name": "Edited By Other"})
|
||||||
|
assert renamed.status_code == 200
|
||||||
|
assert renamed.json()["name"] == "Edited By Other"
|
||||||
finally:
|
finally:
|
||||||
client.headers.update({"Authorization": original_auth})
|
client.headers.update({"Authorization": original_auth})
|
||||||
|
|
||||||
|
assert client.get(f"/api/projects/{owner_project['id']}").json()["name"] == "Edited By Other"
|
||||||
|
|||||||
@@ -64,12 +64,12 @@
|
|||||||
3. FastAPI `backend/routers/auth.py` 查询 `users` 表并校验密码哈希。
|
3. FastAPI `backend/routers/auth.py` 查询 `users` 表并校验密码哈希。
|
||||||
4. 前端把返回 JWT 写入 localStorage,并把用户资料写入 store。
|
4. 前端把返回 JWT 写入 localStorage,并把用户资料写入 store。
|
||||||
5. 后续业务请求带 `Authorization: Bearer <token>`,后端按当前用户过滤项目资源。
|
5. 后续业务请求带 `Authorization: Bearer <token>`,后端按当前用户过滤项目资源。
|
||||||
6. `admin/annotator` 可调用写入类业务接口,`viewer` 只能读取;`/api/admin/*` 仅允许 `admin`。
|
6. 系统只支持唯一默认 `admin` 和 `annotator`;`admin/annotator` 可调用写入类业务接口;`/api/admin/*` 仅允许默认 `admin`。
|
||||||
|
|
||||||
### 管理员用户管理
|
### 管理员用户管理
|
||||||
|
|
||||||
1. `Sidebar.tsx` 仅对 `currentUser.role === 'admin'` 显示“用户管理”。
|
1. `Sidebar.tsx` 仅对 `currentUser.role === 'admin'` 显示“用户管理”。
|
||||||
2. `UserAdmin.tsx` 调用 `GET/POST/PATCH/DELETE /api/admin/users` 完成用户新增、停用/启用、角色修改、改密码和删除无项目用户。
|
2. `UserAdmin.tsx` 调用 `GET/POST/PATCH/DELETE /api/admin/users` 完成标注员新增、停用/启用、改密码和删除用户;不提供观察员或第二个管理员入口。
|
||||||
3. `UserAdmin.tsx` 调用 `GET /api/admin/audit-logs` 展示登录成功/失败以及用户管理操作审计。
|
3. `UserAdmin.tsx` 调用 `GET /api/admin/audit-logs` 展示登录成功/失败以及用户管理操作审计。
|
||||||
4. `UserAdmin.tsx` 危险区“恢复演示出厂设置”需要浏览器确认和输入 `RESET_DEMO_FACTORY`,随后调用 `POST /api/admin/demo-factory-reset`。
|
4. `UserAdmin.tsx` 危险区“恢复演示出厂设置”需要浏览器确认和输入 `RESET_DEMO_FACTORY`,随后调用 `POST /api/admin/demo-factory-reset`。
|
||||||
5. 后端 `backend/routers/admin.py` 会阻止管理员删除、停用或降级自己,并阻止删除仍拥有项目的用户;演示出厂重置会清空其它用户、项目帧、标注、任务和私有模板,重新创建演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目。
|
5. 后端 `backend/routers/admin.py` 会阻止管理员删除、停用或降级自己,并阻止删除仍拥有项目的用户;演示出厂重置会清空其它用户、项目帧、标注、任务和私有模板,重新创建演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目。
|
||||||
@@ -114,4 +114,4 @@
|
|||||||
- AI 当前启用 SAM 2.1 tiny/small/base+/large 点/框/interactive 路径;语义文本提示和 SAM 3 产品入口已禁用,`model=sam3` 会被后端拒绝。SAM 3 源码保留但不计入当前可用功能。
|
- AI 当前启用 SAM 2.1 tiny/small/base+/large 点/框/interactive 路径;语义文本提示和 SAM 3 产品入口已禁用,`model=sam3` 会被后端拒绝。SAM 3 源码保留但不计入当前可用功能。
|
||||||
- 工作区顶部“分割结果导出”和保存状态按钮、左侧工具栏“导入 GT Mask”已接入统一导出、GT 多类别导入、标注新增和 dirty 标注更新;导入 GT Mask 仅支持 8-bit 二值/灰度 maskid 图和 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图,未知 maskid 可由用户选择舍弃或导入为未定义类别,16-bit/uint16 GT_label 和普通彩色类别图会被拒绝,尺寸不同会自动最近邻拉伸到当前帧;GT 连通域会生成高精度 polygon,导入后和普通 mask 一样不显示黄色 seed point,并与普通 mask 共用拓扑统计、边缘平滑、编辑和保存链路。保存状态按钮会按待保存数量显示“保存 X 个改动”或“已全部保存”;统一导出可选择整体视频、特定范围帧或当前图片,并勾选分开 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图;特定范围帧导出支持直接输入起止帧,也支持在播放进度条或视频处理进度条上点击/拖拽选择范围;Mix_label 支持默认 0.3 的透明度调节和首帧预览;后端统一导出 ZIP 固定包含 maskid/GT 像素值映射 JSON 与原始图片文件夹,GT_label 固定输出 8-bit uint8 PNG,像素值使用类别真实 maskid,其中 `maskid:0` 的“待分类”和背景同为 0,缺失 maskid 的旧标注才补下一个可用正整数,正整数 maskid 超出 1-255 会拒绝导出,并按客户命名规则输出分开 Mask、GT_label、Pro_label 和 Mix_label 文件夹;清空当前帧遮罩会删除对应后端标注,存在传播链时同一弹窗提供取消/当前帧/按帧范围选择/所有传播帧,按范围清空复用时间轴范围选择和最终确认;按范围或全部清空遇到人工/AI 标注帧时会二次确认,选择保留则整帧保留。手工绘制、polygon 顶点拖动/删除、区域合并/去除和撤销重做已经落到前端 mask 数据结构;多边形、矩形、圆和画笔创建遵循“有选中 mask 则并入选中 mask、无选中 mask 才新建”的规则,即使新几何和旧区域不重叠也会组成同一个多 polygon mask;无选中分类的新建多边形/矩形/圆会默认归入 `maskid:0` 的“待分类”,画笔无选中 mask 时仍要求右侧语义分类树有 active class;`Esc` 只取消选区和临时绘制状态,不删除已有 mask。
|
- 工作区顶部“分割结果导出”和保存状态按钮、左侧工具栏“导入 GT Mask”已接入统一导出、GT 多类别导入、标注新增和 dirty 标注更新;导入 GT Mask 仅支持 8-bit 二值/灰度 maskid 图和 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图,未知 maskid 可由用户选择舍弃或导入为未定义类别,16-bit/uint16 GT_label 和普通彩色类别图会被拒绝,尺寸不同会自动最近邻拉伸到当前帧;GT 连通域会生成高精度 polygon,导入后和普通 mask 一样不显示黄色 seed point,并与普通 mask 共用拓扑统计、边缘平滑、编辑和保存链路。保存状态按钮会按待保存数量显示“保存 X 个改动”或“已全部保存”;统一导出可选择整体视频、特定范围帧或当前图片,并勾选分开 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图;特定范围帧导出支持直接输入起止帧,也支持在播放进度条或视频处理进度条上点击/拖拽选择范围;Mix_label 支持默认 0.3 的透明度调节和首帧预览;后端统一导出 ZIP 固定包含 maskid/GT 像素值映射 JSON 与原始图片文件夹,GT_label 固定输出 8-bit uint8 PNG,像素值使用类别真实 maskid,其中 `maskid:0` 的“待分类”和背景同为 0,缺失 maskid 的旧标注才补下一个可用正整数,正整数 maskid 超出 1-255 会拒绝导出,并按客户命名规则输出分开 Mask、GT_label、Pro_label 和 Mix_label 文件夹;清空当前帧遮罩会删除对应后端标注,存在传播链时同一弹窗提供取消/当前帧/按帧范围选择/所有传播帧,按范围清空复用时间轴范围选择和最终确认;按范围或全部清空遇到人工/AI 标注帧时会二次确认,选择保留则整帧保留。手工绘制、polygon 顶点拖动/删除、区域合并/去除和撤销重做已经落到前端 mask 数据结构;多边形、矩形、圆和画笔创建遵循“有选中 mask 则并入选中 mask、无选中 mask 才新建”的规则,即使新几何和旧区域不重叠也会组成同一个多 polygon mask;无选中分类的新建多边形/矩形/圆会默认归入 `maskid:0` 的“待分类”,画笔无选中 mask 时仍要求右侧语义分类树有 active class;`Esc` 只取消选区和临时绘制状态,不删除已有 mask。
|
||||||
- Dashboard 初始统计、队列和活动日志来自后端聚合接口;解析队列来自 `processing_tasks`,worker 进度通过 Redis `seg:progress` 转发到 WebSocket。任务取消、重试和失败详情已接入前后端。
|
- Dashboard 初始统计、队列和活动日志来自后端聚合接口;解析队列来自 `processing_tasks`,worker 进度通过 Redis `seg:progress` 转发到 WebSocket。任务取消、重试和失败详情已接入前后端。
|
||||||
- 后端已接入 Bearer JWT 鉴权、当前用户项目隔离和角色权限;写入类业务接口要求 `admin/annotator`,管理员用户后台要求 `admin`。当前审计覆盖登录和用户管理操作,全业务级审计仍可继续扩展。
|
- 后端已接入 Bearer JWT 鉴权、共享项目库和角色权限;写入类业务接口要求 `admin/annotator`,管理员用户后台要求默认 `admin`。当前审计覆盖登录和用户管理操作,全业务级审计仍可继续扩展。
|
||||||
|
|||||||
@@ -32,8 +32,8 @@
|
|||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 侧栏“用户管理”入口 | 真实可用 | 仅当前用户 `role=admin` 时显示;非管理员无法看到入口,后端 `/api/admin/*` 也会返回 403 |
|
| 侧栏“用户管理”入口 | 真实可用 | 仅当前用户 `role=admin` 时显示;非管理员无法看到入口,后端 `/api/admin/*` 也会返回 403 |
|
||||||
| 用户列表 | 真实可用 | 调用 `GET /api/admin/users`,展示用户 id、用户名、角色、启停用状态和创建时间 |
|
| 用户列表 | 真实可用 | 调用 `GET /api/admin/users`,展示用户 id、用户名、角色、启停用状态和创建时间 |
|
||||||
| 新增用户 | 真实可用 | 调用 `POST /api/admin/users`,支持设置用户名、初始密码和 `admin/annotator/viewer` 角色;后端校验用户名唯一和密码长度 |
|
| 新增用户 | 真实可用 | 调用 `POST /api/admin/users`,支持设置用户名和初始密码,新用户固定为标注员;后端校验用户名唯一、密码长度,并拒绝第二个管理员或观察员角色 |
|
||||||
| 修改角色 / 启停用 / 改密码 | 真实可用 | 调用 `PATCH /api/admin/users/{id}`;后端禁止管理员把自己降级或停用,避免锁死后台 |
|
| 启停用 / 改密码 | 真实可用 | 调用 `PATCH /api/admin/users/{id}`;后端禁止管理员把自己降级、改名或停用,避免锁死后台 |
|
||||||
| 删除用户 | 真实可用 | 调用 `DELETE /api/admin/users/{id}`;后端禁止删除自己,且用户名下仍有项目时返回 409,避免悬空项目数据 |
|
| 删除用户 | 真实可用 | 调用 `DELETE /api/admin/users/{id}`;后端禁止删除自己,且用户名下仍有项目时返回 409,避免悬空项目数据 |
|
||||||
| 审计日志 | 真实可用 | 调用 `GET /api/admin/audit-logs`,展示登录成功/失败、用户新增、修改和删除等管理操作 |
|
| 审计日志 | 真实可用 | 调用 `GET /api/admin/audit-logs`,展示登录成功/失败、用户新增、修改和删除等管理操作 |
|
||||||
| 恢复演示出厂设置 | 真实可用 | 管理员点击危险区按钮后先浏览器确认,再输入 `RESET_DEMO_FACTORY`;前端调用 `POST /api/admin/demo-factory-reset`,后端只保留默认 admin、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目,并清空用户、项目帧、标注、任务和私有模板等演示数据;“腹腔镜胆囊切除术”和“头颈部CT分割”系统模板会按内置默认定义重建或覆盖恢复 |
|
| 恢复演示出厂设置 | 真实可用 | 管理员点击危险区按钮后先浏览器确认,再输入 `RESET_DEMO_FACTORY`;前端调用 `POST /api/admin/demo-factory-reset`,后端只保留默认 admin、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目,并清空用户、项目帧、标注、任务和私有模板等演示数据;“腹腔镜胆囊切除术”和“头颈部CT分割”系统模板会按内置默认定义重建或覆盖恢复 |
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ timeout: 30000
|
|||||||
Authorization: Bearer <token>
|
Authorization: Bearer <token>
|
||||||
```
|
```
|
||||||
|
|
||||||
当前后端业务接口会校验该 header。缺失、过期或无效 token 返回 401;项目、帧、标注、任务、Dashboard 和导出会按当前用户拥有的项目过滤。
|
当前后端业务接口会校验该 header。缺失、过期或无效 token 返回 401;项目、帧、标注、任务、Dashboard 和导出使用全员共享项目库,所有登录用户可读取,`admin/annotator` 可写入。
|
||||||
|
|
||||||
## 前端封装的 API
|
## 前端封装的 API
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,9 @@
|
|||||||
- 页面刷新后前端会用已有 token 调用 `/api/auth/me` 恢复当前用户。
|
- 页面刷新后前端会用已有 token 调用 `/api/auth/me` 恢复当前用户。
|
||||||
- 登录失败时显示错误信息。
|
- 登录失败时显示错误信息。
|
||||||
- 业务接口必须校验 Bearer token;缺失或无效 token 返回 401。
|
- 业务接口必须校验 Bearer token;缺失或无效 token 返回 401。
|
||||||
- 项目、帧、标注、任务、Dashboard 和导出必须按当前用户的项目隔离;用户不能读取、修改或删除其他用户项目资源。
|
- 项目、帧、标注、任务、Dashboard 和导出使用全员共享项目库;所有登录用户读取同一项目库,`admin/annotator` 可创建、导入、解析、标注、AI 推理、导出、复制、重命名和删除项目。
|
||||||
- 角色包括 `admin`、`annotator` 和 `viewer`;`admin/annotator` 可写入业务数据和触发 AI/传播,`viewer` 只能访问读接口,用户管理后台仅 `admin` 可用。
|
- 角色只包括唯一默认 `admin` 和 `annotator`;历史 `viewer` 或额外管理员会归一为标注员;用户管理、审计日志和演示环境出厂设置后台仅默认 `admin` 可用。
|
||||||
- 管理员侧栏显示“用户管理”入口;管理员可以新增用户、修改角色、停用/启用、修改密码、删除无项目用户。
|
- 管理员侧栏显示“用户管理”入口;管理员可以新增标注员、停用/启用、修改密码、删除用户。
|
||||||
- 系统记录登录成功/失败和用户管理操作到 `audit_logs`,管理员后台可查看最近审计日志。
|
- 系统记录登录成功/失败和用户管理操作到 `audit_logs`,管理员后台可查看最近审计日志。
|
||||||
- 管理员后台提供“恢复演示出厂设置”危险操作;前端必须二次确认,后端也必须校验 `confirmation=RESET_DEMO_FACTORY`,执行后只保留默认 admin 账号、系统模板、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目,清空其它用户、项目、帧、标注、任务、用户模板和旧审计记录,并写入本次重置审计。
|
- 管理员后台提供“恢复演示出厂设置”危险操作;前端必须二次确认,后端也必须校验 `confirmation=RESET_DEMO_FACTORY`,执行后只保留默认 admin 账号、系统模板、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目,清空其它用户、项目、帧、标注、任务、用户模板和旧审计记录,并写入本次重置审计。
|
||||||
- 系统默认模板至少包含“腹腔镜胆囊切除术”和“头颈部CT分割”;头颈部 CT 默认分类名必须使用纯中文,不带括号英文翻译;恢复演示出厂设置不得删除系统默认模板,并必须重建缺失的默认模板、覆盖恢复被修改或删减的默认语义分类树。
|
- 系统默认模板至少包含“腹腔镜胆囊切除术”和“头颈部CT分割”;头颈部 CT 默认分类名必须使用纯中文,不带括号英文翻译;恢复演示出厂设置不得删除系统默认模板,并必须重建缺失的默认模板、覆盖恢复被修改或删减的默认语义分类树。
|
||||||
|
|||||||
@@ -84,11 +84,11 @@
|
|||||||
|
|
||||||
### 用户隔离
|
### 用户隔离
|
||||||
|
|
||||||
1. `Project.owner_user_id` 指向 `users.id`;启动时默认 admin 用户会被创建,历史 `owner_user_id IS NULL` 的项目会迁移归属到 admin。
|
1. `Project.owner_user_id` 指向 `users.id` 作为创建者/历史元数据保留;项目库访问不再按该字段隔离,所有登录用户共享同一项目库。
|
||||||
2. 项目、帧、媒体上传/拆帧、AI 标注、传播任务、任务列表、Dashboard 和导出接口都通过当前 JWT 用户过滤项目资源。
|
2. 项目、帧、媒体上传/拆帧、AI 标注、传播任务、任务列表、Dashboard 和导出接口都通过当前 JWT 用户过滤项目资源。
|
||||||
3. `Template.owner_user_id` 支持用户模板;`owner_user_id IS NULL` 的模板视为系统模板,可作为默认分类体系对用户可见。
|
3. `Template.owner_user_id` 支持用户模板;`owner_user_id IS NULL` 的模板视为系统模板,可作为默认分类体系对用户可见。
|
||||||
4. 角色分为 `admin`、`annotator`、`viewer`:`admin/annotator` 可调用写入类业务接口,`viewer` 只能调用读接口;`/api/admin/*` 仅允许 `admin`。
|
4. 角色只分为唯一默认 `admin` 和 `annotator`:`admin/annotator` 可调用写入类业务接口;`/api/admin/*` 仅允许默认 `admin`,用于用户管理、审计日志和演示环境出厂设置。
|
||||||
5. `UserAdmin.tsx` 仅在当前用户角色为 `admin` 时从 `Sidebar` 展示,调用 `/api/admin/users` 完成新增、角色修改、停用/启用、密码修改和删除无项目用户,调用 `/api/admin/audit-logs` 展示登录和管理操作审计;改密码、删除用户和危险区“恢复演示出厂设置”均使用站内弹窗确认,恢复出厂设置要求输入 `RESET_DEMO_FACTORY` 后调用 `/api/admin/demo-factory-reset`。
|
5. `UserAdmin.tsx` 仅在当前用户角色为 `admin` 时从 `Sidebar` 展示,调用 `/api/admin/users` 完成标注员新增、停用/启用、密码修改和删除用户,调用 `/api/admin/audit-logs` 展示登录和管理操作审计;改密码、删除用户和危险区“恢复演示出厂设置”均使用站内弹窗确认,恢复出厂设置要求输入 `RESET_DEMO_FACTORY` 后调用 `/api/admin/demo-factory-reset`。
|
||||||
6. `POST /api/admin/demo-factory-reset` 仅允许 `admin`,会重置默认 admin 密码/角色/启用状态,删除其它用户、项目、帧、标注、mask、任务、用户模板和旧审计,重新创建 `settings.demo_video_path` 指向的演示视频项目,以及 `settings.demo_dicom_dir` 指向的演示 DICOM 项目;DICOM 会按文件名自然顺序上传和生成帧;系统模板保留以保证重置后仍可标注。
|
6. `POST /api/admin/demo-factory-reset` 仅允许 `admin`,会重置默认 admin 密码/角色/启用状态,删除其它用户、项目、帧、标注、mask、任务、用户模板和旧审计,重新创建 `settings.demo_video_path` 指向的演示视频项目,以及 `settings.demo_dicom_dir` 指向的演示 DICOM 项目;DICOM 会按文件名自然顺序上传和生成帧;系统模板保留以保证重置后仍可标注。
|
||||||
7. 缺失、过期或伪造的 Bearer token 会在业务路由返回 401,权限不足返回 403,其他用户项目资源对当前用户表现为 404。
|
7. 缺失、过期或伪造的 Bearer token 会在业务路由返回 401,权限不足返回 403,其他用户项目资源对当前用户表现为 404。
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
| 需求 | 测试文件 | 覆盖点 |
|
| 需求 | 测试文件 | 覆盖点 |
|
||||||
|------|----------|--------|
|
|------|----------|--------|
|
||||||
| R1 登录与会话 | `src/components/Login.test.tsx`, `src/components/Sidebar.test.tsx`, `src/components/UserAdmin.test.tsx`, `src/store/useStore.test.ts`, `backend/tests/test_auth.py`, `backend/tests/test_admin.py` | 成功登录、JWT/token 写入、当前用户写入、刷新恢复基础状态、失败提示、登录输入 autocomplete、后端 401、`/api/auth/me`、管理员入口、用户 CRUD、角色权限、审计日志、viewer 读写权限边界、改密码/删除用户站内确认、演示出厂设置站内二次确认和重置结果 |
|
| R1 登录与会话 | `src/components/Login.test.tsx`, `src/components/Sidebar.test.tsx`, `src/components/UserAdmin.test.tsx`, `src/store/useStore.test.ts`, `backend/tests/test_auth.py`, `backend/tests/test_admin.py` | 成功登录、JWT/token 写入、当前用户写入、刷新恢复基础状态、失败提示、登录输入 autocomplete、后端 401、`/api/auth/me`、管理员入口、用户 CRUD、唯一 admin/标注员角色权限、审计日志、旧 viewer 归一为标注员、改密码/删除用户站内确认、演示出厂设置站内二次确认和重置结果 |
|
||||||
| R2 项目管理 | `src/lib/api.test.ts`, `src/components/ProjectLibrary.test.tsx`, `backend/tests/test_projects.py` | 前端字段映射、PATCH 更新、项目卡片复制/删除、修改项目名称时隐藏生成帧、DICOM 项目不显示生成帧、复制项目 reset/full 契约、DELETE 契约、后端 CRUD、删除级联、帧列表、项目按当前 JWT 用户隔离 |
|
| R2 项目管理 | `src/lib/api.test.ts`, `src/components/ProjectLibrary.test.tsx`, `backend/tests/test_projects.py` | 前端字段映射、PATCH 更新、项目卡片复制/删除、修改项目名称时隐藏生成帧、DICOM 项目不显示生成帧、复制项目 reset/full 契约、DELETE 契约、后端 CRUD、删除级联、帧列表、项目按当前 JWT 用户隔离 |
|
||||||
| R3 媒体上传与拆帧 | `src/components/ProjectLibrary.test.tsx`, `src/components/TransientNotice.test.tsx`, `backend/tests/test_media.py`, `backend/tests/test_tasks.py` | 视频导入不自动拆帧、视频/DICOM 上传进度可视化、DICOM 导入显示有效文件数量并在上传后持续显示解析任务进度、显式生成帧 FPS 选择、视频生成帧入队后轮询解析任务并在成功后自动刷新项目封面、项目卡片显示目标 parse_fps 而非原视频 FPS、扩展名校验、自动建项目、关联项目、创建异步任务、非阻塞自动消失操作提示、标准帧序列参数、帧时间戳/源帧号、任务序列元数据、worker 注册帧、取消任务、重试任务、取消后 worker 停止 |
|
| R3 媒体上传与拆帧 | `src/components/ProjectLibrary.test.tsx`, `src/components/TransientNotice.test.tsx`, `backend/tests/test_media.py`, `backend/tests/test_tasks.py` | 视频导入不自动拆帧、视频/DICOM 上传进度可视化、DICOM 导入显示有效文件数量并在上传后持续显示解析任务进度、显式生成帧 FPS 选择、视频生成帧入队后轮询解析任务并在成功后自动刷新项目封面、项目卡片显示目标 parse_fps 而非原视频 FPS、扩展名校验、自动建项目、关联项目、创建异步任务、非阻塞自动消失操作提示、标准帧序列参数、帧时间戳/源帧号、任务序列元数据、worker 注册帧、取消任务、重试任务、取消后 worker 停止 |
|
||||||
| R4 工作区与帧浏览 | `src/components/VideoWorkspace.test.tsx`, `src/components/FrameTimeline.test.tsx` | 加载帧、无帧项目不自动解析并提示生成帧、工作区短状态自动消失、工作区/AI 画布底图默认居中且保留边距、工作区 mask 透明度、回显已保存标注时保留本地未保存 draft mask、选中 mask 后跨帧自动跟随同一传播链结果、左侧工具栏清空遮罩优先作用于当前帧选中 mask/无选中时作用于当前帧全部 mask、无传播链时直接执行、有传播链时可选取消/只清当前帧/按帧范围选择/清空所有传播帧且按范围清空需最终确认、按范围清空或清空所有传播帧遇到人工/AI 标注帧时二次询问并支持保留人工帧、顶栏不显示重复的清空片段遮罩、传播进度存在时任务 message 只显示在蓝色进度面板内且不重复出现在灰色状态文字里、传播链布尔操作按帧范围选择并二次确认、清空/删除前预检后端 annotation id 并跳过本地陈旧 id、删除单个传播 mask 后空帧不保留传播历史颜色、传播权重下拉深色可读配色、自动传播范围选择时显示传播权重和向前/向后帧数、缩略图/range/视频处理进度条、视频处理进度条点击跳帧、人工/AI 标注帧红色竖线和标识点击跳帧、自动传播帧通过 source/lineage metadata 识别为蓝色区段和标识点击跳帧、最近自动传播历史片段同一蓝色系按新旧递进纯色显示,旧记录第 5 次后统一阈值色、当前帧白色贯穿线、传播/布尔/清空范围边界贯穿线、缩略图红/蓝边框、人工/AI 标注帧叠加传播状态时红框优先保留并显示蓝色内描边、当前人工/AI 标注帧青色外框加红色内描边、普通状态不显示传播范围黄色选区、播放进度条和视频处理进度条选择传播/布尔/清空范围、左右方向键切帧、播放、按项目 FPS 显示当前/总时长 |
|
| R4 工作区与帧浏览 | `src/components/VideoWorkspace.test.tsx`, `src/components/FrameTimeline.test.tsx` | 加载帧、无帧项目不自动解析并提示生成帧、工作区短状态自动消失、工作区/AI 画布底图默认居中且保留边距、工作区 mask 透明度、回显已保存标注时保留本地未保存 draft mask、选中 mask 后跨帧自动跟随同一传播链结果、左侧工具栏清空遮罩优先作用于当前帧选中 mask/无选中时作用于当前帧全部 mask、无传播链时直接执行、有传播链时可选取消/只清当前帧/按帧范围选择/清空所有传播帧且按范围清空需最终确认、按范围清空或清空所有传播帧遇到人工/AI 标注帧时二次询问并支持保留人工帧、顶栏不显示重复的清空片段遮罩、传播进度存在时任务 message 只显示在蓝色进度面板内且不重复出现在灰色状态文字里、传播链布尔操作按帧范围选择并二次确认、清空/删除前预检后端 annotation id 并跳过本地陈旧 id、删除单个传播 mask 后空帧不保留传播历史颜色、传播权重下拉深色可读配色、自动传播范围选择时显示传播权重和向前/向后帧数、缩略图/range/视频处理进度条、视频处理进度条点击跳帧、人工/AI 标注帧红色竖线和标识点击跳帧、自动传播帧通过 source/lineage metadata 识别为蓝色区段和标识点击跳帧、最近自动传播历史片段同一蓝色系按新旧递进纯色显示,旧记录第 5 次后统一阈值色、当前帧白色贯穿线、传播/布尔/清空范围边界贯穿线、缩略图红/蓝边框、人工/AI 标注帧叠加传播状态时红框优先保留并显示蓝色内描边、当前人工/AI 标注帧青色外框加红色内描边、普通状态不显示传播范围黄色选区、播放进度条和视频处理进度条选择传播/布尔/清空范围、左右方向键切帧、播放、按项目 FPS 显示当前/总时长 |
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
|
|
||||||
| 需求 | 功能点 | 对应测试 | 当前状态 |
|
| 需求 | 功能点 | 对应测试 | 当前状态 |
|
||||||
|------|--------|----------|----------|
|
|------|--------|----------|----------|
|
||||||
| R1 | 登录页、默认开发管理员、JWT 写入、当前用户写入、刷新恢复基础状态、失败提示、后端 401、`/api/auth/me`、管理员用户管理、角色权限、审计日志、演示出厂设置二次确认、重置后只保留 admin、演示视频项目和已生成帧的自然排序演示 DICOM 项目 | `Login.test.tsx`, `Sidebar.test.tsx`, `UserAdmin.test.tsx`, `useStore.test.ts`, `test_auth.py`, `test_admin.py` | 已覆盖 |
|
| R1 | 登录页、唯一默认管理员、JWT 写入、当前用户写入、刷新恢复基础状态、失败提示、后端 401、`/api/auth/me`、管理员用户管理、角色权限、审计日志、演示出厂设置二次确认、重置后只保留 admin、演示视频项目和已生成帧的自然排序演示 DICOM 项目 | `Login.test.tsx`, `Sidebar.test.tsx`, `UserAdmin.test.tsx`, `useStore.test.ts`, `test_auth.py`, `test_admin.py` | 已覆盖 |
|
||||||
| R2 | 项目列表/创建/选择/重命名/复制、重命名时不触发生成帧、DICOM 不显示生成帧、项目复制 reset/full、项目按用户隔离、视频导入、DICOM 导入、DICOM 前端选择自然排序、后端项目和帧 CRUD | `ProjectLibrary.test.tsx`, `api.test.ts`, `test_projects.py` | 已覆盖 |
|
| R2 | 项目列表/创建/选择/重命名/复制、重命名时不触发生成帧、DICOM 不显示生成帧、项目复制 reset/full、项目按用户隔离、视频导入、DICOM 导入、DICOM 前端选择自然排序、后端项目和帧 CRUD | `ProjectLibrary.test.tsx`, `api.test.ts`, `test_projects.py` | 已覆盖 |
|
||||||
| R3 | 文件类型校验、自动/指定项目上传、视频导入与生成帧分离、视频/DICOM 上传进度可视化、DICOM 导入显示有效文件数量并在上传后持续显示解析任务进度、显式 FPS 生成帧、视频生成帧完成后自动刷新项目封面、项目卡片 FPS 徽标显示 `parse_fps`、视频/DICOM 拆帧任务、DICOM 上传/下载/读取自然排序、非阻塞自动消失操作提示、`parse_fps/max_frames/target_width`、标准帧序列 metadata、任务查询、取消、重试、worker 取消停止 | `ProjectLibrary.test.tsx`, `TransientNotice.test.tsx`, `api.test.ts`, `test_media.py`, `test_tasks.py` | 已覆盖 |
|
| R3 | 文件类型校验、自动/指定项目上传、视频导入与生成帧分离、视频/DICOM 上传进度可视化、DICOM 导入显示有效文件数量并在上传后持续显示解析任务进度、显式 FPS 生成帧、视频生成帧完成后自动刷新项目封面、项目卡片 FPS 徽标显示 `parse_fps`、视频/DICOM 拆帧任务、DICOM 上传/下载/读取自然排序、非阻塞自动消失操作提示、`parse_fps/max_frames/target_width`、标准帧序列 metadata、任务查询、取消、重试、worker 取消停止 | `ProjectLibrary.test.tsx`, `TransientNotice.test.tsx`, `api.test.ts`, `test_media.py`, `test_tasks.py` | 已覆盖 |
|
||||||
| R4 | 工作区加载帧、无帧项目不自动解析、工作区短状态自动消失、后端标注回显保留本地未保存 draft mask、Canvas/AI 底图居中适配且保留边距、工作区 mask 透明度、选中 mask 后跨帧自动跟随同一传播链结果、左侧工具栏当前帧清空优先作用于选中 mask、无传播链时直接执行、有传播链时可选当前帧/传播所有帧/取消、清空人工/AI 标注帧前二次确认、取消确认不删除、仅自动传播帧不确认、删除单个传播 mask 后空帧不保留传播历史颜色、传播权重下拉深色可读配色、缩略图/range/视频处理进度条、视频处理进度条点击跳帧、人工/AI 标注帧红色竖线和标识点击跳帧、自动传播帧蓝色区段和标识点击跳帧、最近自动传播历史片段同一蓝色系按新旧递进显示,旧记录第 5 次后统一阈值色、当前帧白色贯穿线、传播范围洋红/黄绿色边界贯穿线、缩略图红/蓝边框、人工/AI 标注帧叠加传播状态时红框优先保留并显示蓝色内描边、当前人工/AI 标注帧青色外框加红色内描边、普通状态不显示传播范围黄色选区、播放进度条/视频处理进度条拖拽选择传播范围、Canvas/AI 画布拖拽平移回写 position state、左右方向键切帧、播放、按 FPS 显示时间 | `VideoWorkspace.test.tsx`, `FrameTimeline.test.tsx`, `CanvasArea.test.tsx`, `AISegmentation.test.tsx` | 已覆盖 |
|
| R4 | 工作区加载帧、无帧项目不自动解析、工作区短状态自动消失、后端标注回显保留本地未保存 draft mask、Canvas/AI 底图居中适配且保留边距、工作区 mask 透明度、选中 mask 后跨帧自动跟随同一传播链结果、左侧工具栏当前帧清空优先作用于选中 mask、无传播链时直接执行、有传播链时可选当前帧/传播所有帧/取消、清空人工/AI 标注帧前二次确认、取消确认不删除、仅自动传播帧不确认、删除单个传播 mask 后空帧不保留传播历史颜色、传播权重下拉深色可读配色、缩略图/range/视频处理进度条、视频处理进度条点击跳帧、人工/AI 标注帧红色竖线和标识点击跳帧、自动传播帧蓝色区段和标识点击跳帧、最近自动传播历史片段同一蓝色系按新旧递进显示,旧记录第 5 次后统一阈值色、当前帧白色贯穿线、传播范围洋红/黄绿色边界贯穿线、缩略图红/蓝边框、人工/AI 标注帧叠加传播状态时红框优先保留并显示蓝色内描边、当前人工/AI 标注帧青色外框加红色内描边、普通状态不显示传播范围黄色选区、播放进度条/视频处理进度条拖拽选择传播范围、Canvas/AI 画布拖拽平移回写 position state、左右方向键切帧、播放、按 FPS 显示时间 | `VideoWorkspace.test.tsx`, `FrameTimeline.test.tsx`, `CanvasArea.test.tsx`, `AISegmentation.test.tsx` | 已覆盖 |
|
||||||
|
|||||||
@@ -314,7 +314,7 @@ admin / 123456
|
|||||||
|
|
||||||
首次启动会自动创建默认管理员,密码以哈希形式写入 `users` 表;登录返回签名 JWT,业务接口会校验 `Authorization: Bearer <token>`。生产环境必须修改 `jwt_secret_key` 和默认管理员密码。
|
首次启动会自动创建默认管理员,密码以哈希形式写入 `users` 表;登录返回签名 JWT,业务接口会校验 `Authorization: Bearer <token>`。生产环境必须修改 `jwt_secret_key` 和默认管理员密码。
|
||||||
|
|
||||||
默认管理员登录后会看到“用户管理”后台,可新增用户、停用/启用用户、修改角色、重置密码、删除无项目用户并查看登录与用户管理审计日志。角色分为 `admin`、`annotator`、`viewer`:`admin/annotator` 可以执行写入类业务操作,`viewer` 只读。演示部署可在该后台使用“恢复演示出厂设置”,二次确认后只保留默认 admin、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目;视频来自 `demo_video_path`,DICOM 序列来自 `demo_dicom_dir`。
|
默认管理员登录后会看到“用户管理”后台,可新增标注员、停用/启用用户、重置密码、删除用户并查看登录与用户管理审计日志。系统只支持唯一默认 `admin` 和 `annotator` 两类角色:标注员不能新增用户、查看审计日志或恢复演示出厂设置,但可以和管理员共享同一项目库并执行项目管理、标注、AI 推理、任务和导出等业务操作。演示部署可在该后台使用“恢复演示出厂设置”,二次确认后只保留默认 admin、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目;视频来自 `demo_video_path`,DICOM 序列来自 `demo_dicom_dir`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -52,11 +52,11 @@ describe('UserAdmin', () => {
|
|||||||
expect(screen.getByText('当前管理员:admin')).toBeInTheDocument();
|
expect(screen.getByText('当前管理员:admin')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates a user with role and password', async () => {
|
it('creates new users as annotators', async () => {
|
||||||
apiMock.createAdminUser.mockResolvedValueOnce({
|
apiMock.createAdminUser.mockResolvedValueOnce({
|
||||||
id: 3,
|
id: 3,
|
||||||
username: 'nurse',
|
username: 'nurse',
|
||||||
role: 'viewer',
|
role: 'annotator',
|
||||||
is_active: 1,
|
is_active: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -64,30 +64,26 @@ describe('UserAdmin', () => {
|
|||||||
await screen.findByText('doctor');
|
await screen.findByText('doctor');
|
||||||
fireEvent.change(screen.getByPlaceholderText('用户名'), { target: { value: 'nurse' } });
|
fireEvent.change(screen.getByPlaceholderText('用户名'), { target: { value: 'nurse' } });
|
||||||
fireEvent.change(screen.getByPlaceholderText('初始密码'), { target: { value: 'secret123' } });
|
fireEvent.change(screen.getByPlaceholderText('初始密码'), { target: { value: 'secret123' } });
|
||||||
fireEvent.change(screen.getAllByDisplayValue('标注员')[0], { target: { value: 'viewer' } });
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /新增用户/ }));
|
fireEvent.click(screen.getByRole('button', { name: /新增用户/ }));
|
||||||
|
|
||||||
await waitFor(() => expect(apiMock.createAdminUser).toHaveBeenCalledWith({
|
await waitFor(() => expect(apiMock.createAdminUser).toHaveBeenCalledWith({
|
||||||
username: 'nurse',
|
username: 'nurse',
|
||||||
password: 'secret123',
|
password: 'secret123',
|
||||||
role: 'viewer',
|
role: 'annotator',
|
||||||
is_active: true,
|
is_active: true,
|
||||||
}));
|
}));
|
||||||
expect(await screen.findByText('用户已创建')).toBeInTheDocument();
|
expect(await screen.findByText('用户已创建')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('updates role, status and password, and deletes users', async () => {
|
it('updates status and password, and deletes users', async () => {
|
||||||
apiMock.updateAdminUser.mockResolvedValueOnce({ id: 2, username: 'doctor', role: 'viewer', is_active: 1 });
|
apiMock.updateAdminUser.mockResolvedValueOnce({ id: 2, username: 'doctor', role: 'annotator', is_active: 0 });
|
||||||
apiMock.updateAdminUser.mockResolvedValueOnce({ id: 2, username: 'doctor', role: 'viewer', is_active: 0 });
|
apiMock.updateAdminUser.mockResolvedValueOnce({ id: 2, username: 'doctor', role: 'annotator', is_active: 0 });
|
||||||
apiMock.updateAdminUser.mockResolvedValueOnce({ id: 2, username: 'doctor', role: 'viewer', is_active: 0 });
|
|
||||||
apiMock.deleteAdminUser.mockResolvedValueOnce(undefined);
|
apiMock.deleteAdminUser.mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
render(<UserAdmin />);
|
render(<UserAdmin />);
|
||||||
await screen.findByText('doctor');
|
await screen.findByText('doctor');
|
||||||
|
|
||||||
const roleSelects = screen.getAllByDisplayValue('标注员');
|
expect(screen.queryByText('观察员')).not.toBeInTheDocument();
|
||||||
fireEvent.change(roleSelects[1], { target: { value: 'viewer' } });
|
|
||||||
await waitFor(() => expect(apiMock.updateAdminUser).toHaveBeenCalledWith(2, { role: 'viewer' }));
|
|
||||||
|
|
||||||
fireEvent.click(screen.getAllByRole('button', { name: '启用' })[1]);
|
fireEvent.click(screen.getAllByRole('button', { name: '启用' })[1]);
|
||||||
await waitFor(() => expect(apiMock.updateAdminUser).toHaveBeenCalledWith(2, { is_active: false }));
|
await waitFor(() => expect(apiMock.updateAdminUser).toHaveBeenCalledWith(2, { is_active: false }));
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { TransientNotice, type NoticeState, type NoticeTone } from './TransientN
|
|||||||
const roleLabels: Record<string, string> = {
|
const roleLabels: Record<string, string> = {
|
||||||
admin: '管理员',
|
admin: '管理员',
|
||||||
annotator: '标注员',
|
annotator: '标注员',
|
||||||
viewer: '观察员',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatTime(value: string): string {
|
function formatTime(value: string): string {
|
||||||
@@ -47,7 +46,6 @@ export function UserAdmin() {
|
|||||||
const [notice, setNotice] = useState<NoticeState | null>(null);
|
const [notice, setNotice] = useState<NoticeState | null>(null);
|
||||||
const [newUsername, setNewUsername] = useState('');
|
const [newUsername, setNewUsername] = useState('');
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
const [newRole, setNewRole] = useState('annotator');
|
|
||||||
const [passwordTarget, setPasswordTarget] = useState<AdminUser | null>(null);
|
const [passwordTarget, setPasswordTarget] = useState<AdminUser | null>(null);
|
||||||
const [nextPassword, setNextPassword] = useState('');
|
const [nextPassword, setNextPassword] = useState('');
|
||||||
const [deleteUserTarget, setDeleteUserTarget] = useState<AdminUser | null>(null);
|
const [deleteUserTarget, setDeleteUserTarget] = useState<AdminUser | null>(null);
|
||||||
@@ -88,13 +86,12 @@ export function UserAdmin() {
|
|||||||
const created = await createAdminUser({
|
const created = await createAdminUser({
|
||||||
username: newUsername.trim(),
|
username: newUsername.trim(),
|
||||||
password: newPassword,
|
password: newPassword,
|
||||||
role: newRole,
|
role: 'annotator',
|
||||||
is_active: true,
|
is_active: true,
|
||||||
});
|
});
|
||||||
setUsers((prev) => [...prev, created]);
|
setUsers((prev) => [...prev, created]);
|
||||||
setNewUsername('');
|
setNewUsername('');
|
||||||
setNewPassword('');
|
setNewPassword('');
|
||||||
setNewRole('annotator');
|
|
||||||
showNotice('用户已创建', 'success');
|
showNotice('用户已创建', 'success');
|
||||||
setAuditLogs(await getAuditLogs(100));
|
setAuditLogs(await getAuditLogs(100));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -188,7 +185,7 @@ export function UserAdmin() {
|
|||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold text-white">用户管理后台</h1>
|
<h1 className="text-xl font-semibold text-white">用户管理后台</h1>
|
||||||
<p className="mt-1 text-xs text-gray-500">账号、角色、状态和安全审计</p>
|
<p className="mt-1 text-xs text-gray-500">账号、状态和安全审计</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 text-xs text-gray-400">
|
<div className="flex items-center gap-3 text-xs text-gray-400">
|
||||||
<span className="rounded border border-cyan-400/20 bg-cyan-400/10 px-3 py-1 text-cyan-100">
|
<span className="rounded border border-cyan-400/20 bg-cyan-400/10 px-3 py-1 text-cyan-100">
|
||||||
@@ -209,7 +206,7 @@ export function UserAdmin() {
|
|||||||
{isLoading && <Loader2 size={16} className="animate-spin text-cyan-300" />}
|
{isLoading && <Loader2 size={16} className="animate-spin text-cyan-300" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleCreateUser} className="grid grid-cols-[1fr_1fr_150px_auto] gap-2 border-b border-white/10 p-4">
|
<form onSubmit={handleCreateUser} className="grid grid-cols-[1fr_1fr_auto] gap-2 border-b border-white/10 p-4">
|
||||||
<input
|
<input
|
||||||
value={newUsername}
|
value={newUsername}
|
||||||
onChange={(event) => setNewUsername(event.target.value)}
|
onChange={(event) => setNewUsername(event.target.value)}
|
||||||
@@ -225,15 +222,6 @@ export function UserAdmin() {
|
|||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
className="rounded border border-white/10 bg-[#181818] px-3 py-2 text-sm text-white outline-none focus:border-cyan-400/50"
|
className="rounded border border-white/10 bg-[#181818] px-3 py-2 text-sm text-white outline-none focus:border-cyan-400/50"
|
||||||
/>
|
/>
|
||||||
<select
|
|
||||||
value={newRole}
|
|
||||||
onChange={(event) => setNewRole(event.target.value)}
|
|
||||||
className="rounded border border-white/10 bg-[#181818] px-3 py-2 text-sm text-white outline-none focus:border-cyan-400/50"
|
|
||||||
>
|
|
||||||
<option value="annotator">标注员</option>
|
|
||||||
<option value="viewer">观察员</option>
|
|
||||||
<option value="admin">管理员</option>
|
|
||||||
</select>
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
@@ -262,16 +250,16 @@ export function UserAdmin() {
|
|||||||
<div className="text-xs text-gray-500">ID {user.id}</div>
|
<div className="text-xs text-gray-500">ID {user.id}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<select
|
<span
|
||||||
value={user.role}
|
className={cn(
|
||||||
onChange={(event) => void handlePatchUser(user, { role: event.target.value })}
|
'inline-flex rounded border px-2 py-1 text-xs',
|
||||||
disabled={isSaving}
|
user.role === 'admin'
|
||||||
className="rounded border border-white/10 bg-[#181818] px-2 py-1 text-xs text-cyan-100"
|
? 'border-cyan-400/30 bg-cyan-400/10 text-cyan-100'
|
||||||
|
: 'border-white/10 bg-white/5 text-gray-200',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<option value="admin">管理员</option>
|
{roleLabels[user.role] || '标注员'}
|
||||||
<option value="annotator">标注员</option>
|
</span>
|
||||||
<option value="viewer">观察员</option>
|
|
||||||
</select>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -180,12 +180,12 @@ describe('api client contracts', () => {
|
|||||||
.mockResolvedValueOnce({ data: [{ id: 1, username: 'admin', role: 'admin', is_active: 1 }] })
|
.mockResolvedValueOnce({ data: [{ id: 1, username: 'admin', role: 'admin', is_active: 1 }] })
|
||||||
.mockResolvedValueOnce({ data: [{ id: 9, action: 'admin.user_created', created_at: 'now' }] });
|
.mockResolvedValueOnce({ data: [{ id: 9, action: 'admin.user_created', created_at: 'now' }] });
|
||||||
axiosMock.client.post.mockResolvedValueOnce({ data: { id: 2, username: 'doctor', role: 'annotator', is_active: 1 } });
|
axiosMock.client.post.mockResolvedValueOnce({ data: { id: 2, username: 'doctor', role: 'annotator', is_active: 1 } });
|
||||||
axiosMock.client.patch.mockResolvedValueOnce({ data: { id: 2, username: 'doctor', role: 'viewer', is_active: 1 } });
|
axiosMock.client.patch.mockResolvedValueOnce({ data: { id: 2, username: 'doctor', role: 'annotator', is_active: 0 } });
|
||||||
axiosMock.client.delete.mockResolvedValueOnce({ data: null });
|
axiosMock.client.delete.mockResolvedValueOnce({ data: null });
|
||||||
|
|
||||||
await expect(getAdminUsers()).resolves.toEqual([expect.objectContaining({ username: 'admin' })]);
|
await expect(getAdminUsers()).resolves.toEqual([expect.objectContaining({ username: 'admin' })]);
|
||||||
await createAdminUser({ username: 'doctor', password: 'secret123', role: 'annotator', is_active: true });
|
await createAdminUser({ username: 'doctor', password: 'secret123', role: 'annotator', is_active: true });
|
||||||
await updateAdminUser(2, { role: 'viewer' });
|
await updateAdminUser(2, { is_active: false });
|
||||||
await deleteAdminUser(2);
|
await deleteAdminUser(2);
|
||||||
await expect(getAuditLogs(50)).resolves.toEqual([expect.objectContaining({ action: 'admin.user_created' })]);
|
await expect(getAuditLogs(50)).resolves.toEqual([expect.objectContaining({ action: 'admin.user_created' })]);
|
||||||
|
|
||||||
@@ -196,7 +196,7 @@ describe('api client contracts', () => {
|
|||||||
role: 'annotator',
|
role: 'annotator',
|
||||||
is_active: true,
|
is_active: true,
|
||||||
});
|
});
|
||||||
expect(axiosMock.client.patch).toHaveBeenCalledWith('/api/admin/users/2', { role: 'viewer' });
|
expect(axiosMock.client.patch).toHaveBeenCalledWith('/api/admin/users/2', { is_active: false });
|
||||||
expect(axiosMock.client.delete).toHaveBeenCalledWith('/api/admin/users/2');
|
expect(axiosMock.client.delete).toHaveBeenCalledWith('/api/admin/users/2');
|
||||||
expect(axiosMock.client.get).toHaveBeenNthCalledWith(2, '/api/admin/audit-logs', { params: { limit: 50 } });
|
expect(axiosMock.client.get).toHaveBeenNthCalledWith(2, '/api/admin/audit-logs', { params: { limit: 50 } });
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user