- 新增基于 JWT 当前用户的登录恢复、角色权限、用户管理、审计日志和演示出厂重置后台接口与前端管理页。 - 重串 GT_label 导出和 GT Mask 导入逻辑:导出保留类别真实 maskid,导入仅接受灰度或 RGB 等通道 maskid 图,支持未知 maskid 策略、尺寸最近邻拉伸和导入预览。 - 统一分割结果导出体验:默认当前帧,按项目抽帧顺序和 XhXXmXXsXXXms 时间戳命名 ZIP 与图片,补齐 GT/Pro/Mix/分开 Mask 输出和映射 JSON。 - 调整工作区左侧工具栏:移除创建点/线段入口,新增画笔、橡皮擦及尺寸控制,并按绘制、布尔、导入/AI 工具分组分隔。 - 扩展 Canvas 编辑能力:画笔按语义分类绘制并可自动并入连通选中 mask,橡皮擦对选中区域扣除,优化布尔操作、选区、撤销重做和保存状态联动。 - 优化自动传播时间轴显示:同一蓝色系按传播新旧递进变暗,老传播记录达到阈值后统一旧记录色,并维护范围选择与清空后的历史显示。 - 将 AI 智能分割入口替换为更明确的 AI 元素图标,并同步侧栏、工作区和 AI 页面入口表现。 - 完善模板分类、maskid 工具函数、分类树联动、遮罩透明度、边缘平滑和传播链同步相关前端状态。 - 扩展后端项目、媒体、任务、Dashboard、模板和传播 runner 的用户隔离、任务控制、进度事件与兼容处理。 - 补充前后端测试,覆盖用户管理、GT_label 往返导入导出、GT Mask 校验和预览、画笔/橡皮擦、时间轴传播历史、导出范围、WebSocket 与 API 封装。 - 更新 AGENTS、README 和 doc 文档,记录当前接口契约、实现状态、测试计划、安装说明和 maskid/GT_label 规则。
271 lines
9.5 KiB
Python
271 lines
9.5 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 minio_client import upload_file
|
|
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 statuses import PROJECT_STATUS_PENDING
|
|
|
|
router = APIRouter(prefix="/api/admin", tags=["Admin"])
|
|
|
|
VALID_ROLES = {"admin", "annotator", "viewer"}
|
|
DEMO_RESET_CONFIRMATION = "RESET_DEMO_FACTORY"
|
|
DEMO_PROJECT_NAME = "Data_MyVideo_1"
|
|
|
|
|
|
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 and one unparsed demo video 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}",
|
|
)
|
|
|
|
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")
|
|
|
|
project = Project(
|
|
name=DEMO_PROJECT_NAME,
|
|
description="默认演示视频,尚未生成帧",
|
|
status=PROJECT_STATUS_PENDING,
|
|
source_type="video",
|
|
parse_fps=30.0,
|
|
owner_user_id=preserved_admin.id,
|
|
)
|
|
db.add(project)
|
|
db.flush()
|
|
|
|
with open(settings.demo_video_path, "rb") as file_obj:
|
|
data = file_obj.read()
|
|
object_name = f"uploads/{project.id}/{os.path.basename(settings.demo_video_path)}"
|
|
upload_file(object_name, data, content_type="video/mp4", length=len(data))
|
|
project.video_path = object_name
|
|
project.thumbnail_url = None
|
|
project.original_fps = None
|
|
db.commit()
|
|
db.refresh(preserved_admin)
|
|
db.refresh(project)
|
|
|
|
write_audit_log(
|
|
db,
|
|
actor=preserved_admin,
|
|
action="admin.demo_factory_reset",
|
|
target_type="project",
|
|
target_id=project.id,
|
|
detail={
|
|
"project_name": project.name,
|
|
"video_path": project.video_path,
|
|
"deleted_counts": deleted_counts,
|
|
"requested_by": requested_by,
|
|
},
|
|
)
|
|
|
|
return {
|
|
"admin_user": preserved_admin,
|
|
"project": project,
|
|
"deleted_counts": deleted_counts,
|
|
"message": "演示环境已恢复出厂设置",
|
|
}
|