1053 lines
33 KiB
Python
1053 lines
33 KiB
Python
"""用户管理 API。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import csv
|
||
from datetime import datetime
|
||
from io import BytesIO, StringIO
|
||
from typing import Any, Optional
|
||
|
||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||
from pydantic import BaseModel
|
||
from sqlalchemy import and_, delete, func, or_, select, update
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from ..database import get_db
|
||
from ..dependencies import (
|
||
get_current_active_user,
|
||
get_current_user,
|
||
get_tenant_id,
|
||
require_roles,
|
||
require_self_or_manager,
|
||
)
|
||
from ..models import (
|
||
SkillTag,
|
||
Task,
|
||
User,
|
||
UserProject,
|
||
UserRole,
|
||
UserSkillBinding,
|
||
UserStatus,
|
||
UserWorkload,
|
||
)
|
||
from ..schemas import (
|
||
UserCreate,
|
||
UserResetPasswordRequest,
|
||
UserResponse,
|
||
UserStatusPatchRequest,
|
||
UserUpdate,
|
||
)
|
||
from ..services.auth import AuthService
|
||
from ..services.operation_log_service import log_operation
|
||
from ..services import skill_service
|
||
|
||
router = APIRouter(prefix="/users", tags=["users"])
|
||
|
||
COMPLETED_TASK_STATUSES = {"done", "completed", "closed", "resolved", "archived"}
|
||
MANAGER_ROLE_SET = {"BOSS", "ADMIN"}
|
||
VALID_ROLE_VALUES = {r.value for r in UserRole}
|
||
|
||
|
||
class BulkDeleteUsersRequest(BaseModel):
|
||
user_ids: list[int]
|
||
|
||
|
||
class BulkAssignUsersRequest(BaseModel):
|
||
user_ids: list[int]
|
||
role: Optional[str] = None
|
||
project_ids: Optional[list[int]] = None
|
||
|
||
|
||
def _enum_value(value: Any) -> Any:
|
||
return value.value if hasattr(value, "value") else value
|
||
|
||
|
||
def _parse_role_values(raw: Any) -> list[str]:
|
||
if raw is None:
|
||
return []
|
||
if isinstance(raw, (list, tuple, set)):
|
||
values = [str(_enum_value(item)).strip().upper() for item in raw]
|
||
else:
|
||
values = [token.strip().upper() for token in str(_enum_value(raw)).split(",")]
|
||
deduped: list[str] = []
|
||
for value in values:
|
||
if value and value not in deduped:
|
||
deduped.append(value)
|
||
return deduped
|
||
|
||
|
||
def _normalize_role_input(raw: Any, *, allow_empty: bool = False) -> list[str]:
|
||
values = _parse_role_values(raw)
|
||
if not values and not allow_empty:
|
||
raise ValueError("role required")
|
||
invalid = [value for value in values if value not in VALID_ROLE_VALUES]
|
||
if invalid:
|
||
raise ValueError(f"Invalid role: {','.join(invalid)}")
|
||
return values
|
||
|
||
|
||
def _pack_roles(raw: Any) -> str:
|
||
return ",".join(_normalize_role_input(raw))
|
||
|
||
|
||
def _has_any_role(raw: Any, *roles: str) -> bool:
|
||
values = set(_parse_role_values(raw))
|
||
expected = {str(role).upper() for role in roles}
|
||
return bool(values.intersection(expected))
|
||
|
||
|
||
def _build_role_filter(column: Any, roles: list[str]) -> Any:
|
||
filters = []
|
||
for role in roles:
|
||
filters.extend(
|
||
[
|
||
column == role,
|
||
column.like(f"{role},%"),
|
||
column.like(f"%,{role},%"),
|
||
column.like(f"%,{role}"),
|
||
]
|
||
)
|
||
return or_(*filters)
|
||
|
||
|
||
def _parse_int_list(raw: Optional[str]) -> list[int]:
|
||
if not raw:
|
||
return []
|
||
ids: list[int] = []
|
||
for part in raw.split(","):
|
||
token = part.strip()
|
||
if not token:
|
||
continue
|
||
try:
|
||
ids.append(int(token))
|
||
except ValueError:
|
||
continue
|
||
return ids
|
||
|
||
|
||
async def _collect_user_enrichment(
|
||
db: AsyncSession,
|
||
tenant_id: int,
|
||
users: list[User],
|
||
) -> tuple[dict[int, float], dict[int, list[str]], dict[int, list[int]]]:
|
||
if not users:
|
||
return {}, {}, {}
|
||
|
||
user_ids = [int(u.id) for u in users]
|
||
|
||
workloads_result = await db.execute(
|
||
select(UserWorkload).where(
|
||
and_(
|
||
UserWorkload.tenant_id == tenant_id,
|
||
UserWorkload.user_id.in_(user_ids),
|
||
)
|
||
)
|
||
)
|
||
workload_map: dict[int, float] = {
|
||
int(w.user_id): float(w.workload_ratio or 0.0)
|
||
for w in workloads_result.scalars().all()
|
||
}
|
||
|
||
skill_rows = await db.execute(
|
||
select(UserSkillBinding.user_id, SkillTag.name, UserSkillBinding.proficiency)
|
||
.join(SkillTag, SkillTag.id == UserSkillBinding.skill_tag_id)
|
||
.where(
|
||
and_(
|
||
UserSkillBinding.tenant_id == tenant_id,
|
||
UserSkillBinding.user_id.in_(user_ids),
|
||
)
|
||
)
|
||
.order_by(UserSkillBinding.user_id, UserSkillBinding.proficiency.desc())
|
||
)
|
||
top_skills: dict[int, list[str]] = {}
|
||
for uid, skill_name, _prof in skill_rows.all():
|
||
key = int(uid)
|
||
top_skills.setdefault(key, [])
|
||
if len(top_skills[key]) < 3:
|
||
top_skills[key].append(skill_name)
|
||
|
||
project_rows = await db.execute(
|
||
select(UserProject.user_id, UserProject.project_id).where(
|
||
and_(
|
||
UserProject.tenant_id == tenant_id,
|
||
UserProject.user_id.in_(user_ids),
|
||
)
|
||
)
|
||
)
|
||
project_map: dict[int, list[int]] = {}
|
||
for uid, pid in project_rows.all():
|
||
project_map.setdefault(int(uid), []).append(int(pid))
|
||
|
||
return workload_map, top_skills, project_map
|
||
|
||
|
||
def _serialize_user(
|
||
user: User,
|
||
workload_map: dict[int, float] | None = None,
|
||
top_skills: dict[int, list[str]] | None = None,
|
||
project_map: dict[int, list[int]] | None = None,
|
||
) -> dict:
|
||
payload = user.to_dict()
|
||
uid = int(user.id)
|
||
payload["workload_ratio"] = (workload_map or {}).get(uid, float(user.workload or 0))
|
||
payload["top_skills"] = (top_skills or {}).get(uid, [])
|
||
payload["project_ids"] = (project_map or {}).get(uid, [])
|
||
return payload
|
||
|
||
|
||
async def _replace_user_projects(
|
||
db: AsyncSession,
|
||
tenant_id: int,
|
||
user_id: int,
|
||
project_ids: list[int],
|
||
) -> None:
|
||
await db.execute(
|
||
delete(UserProject).where(
|
||
and_(
|
||
UserProject.tenant_id == tenant_id,
|
||
UserProject.user_id == user_id,
|
||
)
|
||
)
|
||
)
|
||
unique_project_ids = sorted(set(project_ids))
|
||
for pid in unique_project_ids:
|
||
db.add(UserProject(tenant_id=tenant_id, user_id=user_id, project_id=pid))
|
||
|
||
|
||
async def _create_user_with_defaults(
|
||
db: AsyncSession,
|
||
tenant_id: int,
|
||
user_data: UserCreate,
|
||
actor_id: int,
|
||
*,
|
||
commit: bool,
|
||
) -> User:
|
||
username = user_data.username.strip()
|
||
if not username:
|
||
raise ValueError("用户名不能为空")
|
||
|
||
existing_username = await db.execute(
|
||
select(User).where(
|
||
and_(
|
||
User.tenant_id == tenant_id,
|
||
User.username == username,
|
||
)
|
||
)
|
||
)
|
||
if existing_username.scalar_one_or_none():
|
||
raise ValueError("用户名已存在")
|
||
|
||
if user_data.email:
|
||
existing_email = await db.execute(
|
||
select(User).where(
|
||
and_(
|
||
User.tenant_id == tenant_id,
|
||
User.email == user_data.email.strip(),
|
||
)
|
||
)
|
||
)
|
||
if existing_email.scalar_one_or_none():
|
||
raise ValueError("邮箱已存在")
|
||
|
||
user = User(
|
||
tenant_id=tenant_id,
|
||
username=username,
|
||
password_hash=AuthService.hash_password(user_data.password),
|
||
name=(user_data.name or username).strip(),
|
||
email=(user_data.email or f"{username}@placeholder.local").strip(),
|
||
role=_pack_roles(user_data.role),
|
||
assigned_domains=user_data.assigned_domains or [],
|
||
workload=0,
|
||
status=UserStatus.ACTIVE.value,
|
||
is_active=True,
|
||
)
|
||
db.add(user)
|
||
await db.flush()
|
||
|
||
db.add(
|
||
UserWorkload(
|
||
tenant_id=tenant_id,
|
||
user_id=int(user.id),
|
||
current_assigned_hours=0.0,
|
||
max_available_hours_per_week=40.0,
|
||
workload_ratio=0.0,
|
||
)
|
||
)
|
||
await skill_service.init_user_skill_bindings(
|
||
db,
|
||
tenant_id=tenant_id,
|
||
user_id=int(user.id),
|
||
include_system_only=True,
|
||
commit=False,
|
||
)
|
||
|
||
if user_data.project_ids:
|
||
await _replace_user_projects(
|
||
db,
|
||
tenant_id=tenant_id,
|
||
user_id=int(user.id),
|
||
project_ids=user_data.project_ids,
|
||
)
|
||
|
||
await log_operation(
|
||
db,
|
||
tenant_id=tenant_id,
|
||
user_id=actor_id,
|
||
project_id=None,
|
||
entity_type="user",
|
||
entity_id=str(user.id),
|
||
action="create",
|
||
before_data=None,
|
||
after_data=user.to_dict(),
|
||
)
|
||
|
||
if commit:
|
||
await db.commit()
|
||
await db.refresh(user)
|
||
return user
|
||
|
||
|
||
@router.get("/me", response_model=UserResponse)
|
||
async def get_current_user_info(current_user: User = Depends(get_current_active_user)):
|
||
"""当前用户信息。"""
|
||
return UserResponse(**_serialize_user(current_user))
|
||
|
||
|
||
@router.get("/")
|
||
async def list_users(
|
||
page: int = Query(1, ge=1),
|
||
page_size: int = Query(20, ge=1, le=100),
|
||
search: Optional[str] = Query(None, description="用户名/姓名/邮箱关键字"),
|
||
role: Optional[str] = Query(None),
|
||
status_value: Optional[str] = Query(None, alias="status"),
|
||
is_active: Optional[bool] = Query(None),
|
||
project_id: Optional[int] = Query(None),
|
||
workload_state: Optional[str] = Query(None, description="idle|normal|overloaded"),
|
||
skill_ids: Optional[str] = Query(None, description="逗号分隔技能ID"),
|
||
tenant_id: int = Depends(get_tenant_id),
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""用户分页列表(支持多维过滤)。"""
|
||
_ = current_user
|
||
skill_id_list = _parse_int_list(skill_ids)
|
||
|
||
query = select(User).where(User.tenant_id == tenant_id)
|
||
if role:
|
||
try:
|
||
role_values = _normalize_role_input(role, allow_empty=True)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc))
|
||
if role_values:
|
||
query = query.where(_build_role_filter(User.role, role_values))
|
||
if status_value:
|
||
query = query.where(User.status == status_value)
|
||
if is_active is not None:
|
||
query = query.where(User.is_active == is_active)
|
||
if search:
|
||
q = f"%{search.strip()}%"
|
||
query = query.where(
|
||
or_(
|
||
User.username.ilike(q),
|
||
User.name.ilike(q),
|
||
User.email.ilike(q),
|
||
)
|
||
)
|
||
|
||
if project_id is not None:
|
||
query = query.join(
|
||
UserProject,
|
||
and_(
|
||
UserProject.user_id == User.id,
|
||
UserProject.tenant_id == tenant_id,
|
||
),
|
||
).where(UserProject.project_id == project_id)
|
||
|
||
if skill_id_list:
|
||
query = query.join(
|
||
UserSkillBinding,
|
||
and_(
|
||
UserSkillBinding.user_id == User.id,
|
||
UserSkillBinding.tenant_id == tenant_id,
|
||
),
|
||
).where(UserSkillBinding.skill_tag_id.in_(skill_id_list))
|
||
|
||
if workload_state:
|
||
query = query.outerjoin(
|
||
UserWorkload,
|
||
and_(
|
||
UserWorkload.user_id == User.id,
|
||
UserWorkload.tenant_id == tenant_id,
|
||
),
|
||
)
|
||
if workload_state == "idle":
|
||
query = query.where(func.coalesce(UserWorkload.workload_ratio, 0.0) < 70.0)
|
||
elif workload_state == "normal":
|
||
query = query.where(
|
||
and_(
|
||
func.coalesce(UserWorkload.workload_ratio, 0.0) >= 70.0,
|
||
func.coalesce(UserWorkload.workload_ratio, 0.0) <= 100.0,
|
||
)
|
||
)
|
||
elif workload_state == "overloaded":
|
||
query = query.where(func.coalesce(UserWorkload.workload_ratio, 0.0) > 100.0)
|
||
|
||
total_result = await db.execute(
|
||
select(func.count()).select_from(query.distinct(User.id).subquery())
|
||
)
|
||
total = int(total_result.scalar() or 0)
|
||
|
||
result = await db.execute(
|
||
query.distinct(User.id)
|
||
.order_by(User.updated_at.desc(), User.id.desc())
|
||
.offset((page - 1) * page_size)
|
||
.limit(page_size)
|
||
)
|
||
users = list(result.scalars().all())
|
||
workload_map, top_skills, project_map = await _collect_user_enrichment(db, tenant_id, users)
|
||
|
||
return {
|
||
"items": [
|
||
_serialize_user(
|
||
u,
|
||
workload_map=workload_map,
|
||
top_skills=top_skills,
|
||
project_map=project_map,
|
||
)
|
||
for u in users
|
||
],
|
||
"page": page,
|
||
"page_size": page_size,
|
||
"total": total,
|
||
}
|
||
|
||
|
||
@router.get("/available")
|
||
async def get_available_staff(
|
||
role: Optional[str] = Query(None, description="按角色过滤"),
|
||
tenant_id: int = Depends(get_tenant_id),
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""获取可分配任务成员(启用 + active)。"""
|
||
_ = current_user
|
||
query = select(User).where(
|
||
and_(
|
||
User.tenant_id == tenant_id,
|
||
User.status == UserStatus.ACTIVE.value,
|
||
User.is_active == True,
|
||
)
|
||
)
|
||
if role:
|
||
try:
|
||
role_values = _normalize_role_input(role, allow_empty=True)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc))
|
||
if role_values:
|
||
query = query.where(_build_role_filter(User.role, role_values))
|
||
|
||
result = await db.execute(query.order_by(User.workload.asc(), User.id.asc()))
|
||
users = result.scalars().all()
|
||
return [_serialize_user(u) for u in users]
|
||
|
||
|
||
@router.get("/{user_id}")
|
||
async def get_user(
|
||
user_id: int,
|
||
tenant_id: int = Depends(get_tenant_id),
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""获取单用户详情。"""
|
||
_ = current_user
|
||
result = await db.execute(
|
||
select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id))
|
||
)
|
||
user = result.scalar_one_or_none()
|
||
if not user:
|
||
raise HTTPException(status_code=404, detail="User not found")
|
||
|
||
workloads, skills, projects = await _collect_user_enrichment(db, tenant_id, [user])
|
||
return _serialize_user(user, workloads, skills, projects)
|
||
|
||
|
||
@router.post("/")
|
||
async def create_user(
|
||
user_data: UserCreate,
|
||
tenant_id: int = Depends(get_tenant_id),
|
||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""创建用户(自动初始化 user_workloads)。"""
|
||
try:
|
||
user = await _create_user_with_defaults(
|
||
db,
|
||
tenant_id=tenant_id,
|
||
user_data=user_data,
|
||
actor_id=int(current_user.id),
|
||
commit=True,
|
||
)
|
||
except ValueError as exc:
|
||
await db.rollback()
|
||
raise HTTPException(status_code=400, detail=str(exc))
|
||
except Exception:
|
||
await db.rollback()
|
||
raise
|
||
|
||
workloads, skills, projects = await _collect_user_enrichment(db, tenant_id, [user])
|
||
return _serialize_user(user, workloads, skills, projects)
|
||
|
||
|
||
@router.put("/{user_id}")
|
||
@router.patch("/{user_id}")
|
||
async def update_user(
|
||
user_id: int,
|
||
user_data: UserUpdate,
|
||
tenant_id: int = Depends(get_tenant_id),
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""更新用户信息。"""
|
||
result = await db.execute(
|
||
select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id))
|
||
)
|
||
user = result.scalar_one_or_none()
|
||
if not user:
|
||
raise HTTPException(status_code=404, detail="User not found")
|
||
|
||
current_role = _enum_value(current_user.role)
|
||
is_self = int(current_user.id) == user_id
|
||
is_manager = _has_any_role(current_role, *MANAGER_ROLE_SET)
|
||
if not is_self and not is_manager:
|
||
raise HTTPException(status_code=403, detail="No permission")
|
||
|
||
if is_self and not is_manager:
|
||
# 普通成员仅可修改自身基础资料。
|
||
if user_data.role is not None or user_data.status is not None or user_data.is_active is not None:
|
||
raise HTTPException(status_code=403, detail="Members can only edit own profile fields")
|
||
if user_data.project_ids is not None:
|
||
raise HTTPException(status_code=403, detail="Members cannot change project assignments")
|
||
|
||
before_data = user.to_dict()
|
||
|
||
if user_data.name is not None:
|
||
user.name = user_data.name.strip()
|
||
if user_data.email is not None:
|
||
user.email = user_data.email.strip()
|
||
if user_data.assigned_domains is not None:
|
||
user.assigned_domains = user_data.assigned_domains
|
||
if user_data.workload is not None:
|
||
user.workload = max(0, min(200, int(user_data.workload)))
|
||
if user_data.status is not None:
|
||
user.status = _enum_value(user_data.status)
|
||
user.is_active = user.status == UserStatus.ACTIVE.value
|
||
if user_data.is_active is not None:
|
||
user.is_active = bool(user_data.is_active)
|
||
if not user.is_active:
|
||
user.status = UserStatus.OFFLINE.value
|
||
elif user.status == UserStatus.OFFLINE.value:
|
||
user.status = UserStatus.ACTIVE.value
|
||
if user_data.role is not None and is_manager:
|
||
try:
|
||
user.role = _pack_roles(user_data.role)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc))
|
||
if user_data.project_ids is not None:
|
||
await _replace_user_projects(
|
||
db,
|
||
tenant_id=tenant_id,
|
||
user_id=user_id,
|
||
project_ids=user_data.project_ids,
|
||
)
|
||
|
||
await log_operation(
|
||
db,
|
||
tenant_id=tenant_id,
|
||
user_id=int(current_user.id),
|
||
project_id=None,
|
||
entity_type="user",
|
||
entity_id=str(user.id),
|
||
action="update",
|
||
before_data=before_data,
|
||
after_data=user.to_dict(),
|
||
)
|
||
await db.commit()
|
||
await db.refresh(user)
|
||
|
||
workloads, skills, projects = await _collect_user_enrichment(db, tenant_id, [user])
|
||
return _serialize_user(user, workloads, skills, projects)
|
||
|
||
|
||
@router.post("/{user_id}/reset-password")
|
||
async def reset_password(
|
||
user_id: int,
|
||
body: UserResetPasswordRequest,
|
||
tenant_id: int = Depends(get_tenant_id),
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""重置用户密码。"""
|
||
result = await db.execute(
|
||
select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id))
|
||
)
|
||
user = result.scalar_one_or_none()
|
||
if not user:
|
||
raise HTTPException(status_code=404, detail="User not found")
|
||
|
||
current_role = _enum_value(current_user.role)
|
||
is_manager = _has_any_role(current_role, *MANAGER_ROLE_SET)
|
||
if not is_manager and int(current_user.id) != user_id:
|
||
raise HTTPException(status_code=403, detail="No permission")
|
||
|
||
if len(body.new_password or "") < 6:
|
||
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
|
||
|
||
user.password_hash = AuthService.hash_password(body.new_password)
|
||
user.updated_at = datetime.utcnow()
|
||
await log_operation(
|
||
db,
|
||
tenant_id=tenant_id,
|
||
user_id=int(current_user.id),
|
||
project_id=None,
|
||
entity_type="user",
|
||
entity_id=str(user.id),
|
||
action="reset_password",
|
||
before_data=None,
|
||
after_data={"updated_at": user.updated_at.isoformat()},
|
||
)
|
||
await db.commit()
|
||
return {"message": "Password reset successfully"}
|
||
|
||
|
||
@router.patch("/{user_id}/status")
|
||
async def patch_user_status(
|
||
user_id: int,
|
||
body: UserStatusPatchRequest,
|
||
tenant_id: int = Depends(get_tenant_id),
|
||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""禁用/启用用户,禁用时清空任务指派并归零负载。"""
|
||
result = await db.execute(
|
||
select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id))
|
||
)
|
||
user = result.scalar_one_or_none()
|
||
if not user:
|
||
raise HTTPException(status_code=404, detail="User not found")
|
||
|
||
before_data = user.to_dict()
|
||
user.is_active = body.is_active
|
||
user.status = UserStatus.ACTIVE.value if body.is_active else UserStatus.OFFLINE.value
|
||
|
||
if not body.is_active:
|
||
user.workload = 0
|
||
await db.execute(
|
||
update(Task)
|
||
.where(
|
||
and_(
|
||
Task.tenant_id == tenant_id,
|
||
Task.assignee_id == user_id,
|
||
)
|
||
)
|
||
.values(assignee_id=None, updated_at=datetime.utcnow())
|
||
)
|
||
workload_result = await db.execute(
|
||
select(UserWorkload).where(
|
||
and_(
|
||
UserWorkload.tenant_id == tenant_id,
|
||
UserWorkload.user_id == user_id,
|
||
)
|
||
)
|
||
)
|
||
workload = workload_result.scalar_one_or_none()
|
||
if not workload:
|
||
workload = UserWorkload(tenant_id=tenant_id, user_id=user_id)
|
||
db.add(workload)
|
||
workload.current_assigned_hours = 0.0
|
||
workload.workload_ratio = 0.0
|
||
|
||
await log_operation(
|
||
db,
|
||
tenant_id=tenant_id,
|
||
user_id=int(current_user.id),
|
||
project_id=None,
|
||
entity_type="user",
|
||
entity_id=str(user.id),
|
||
action="status_patch",
|
||
before_data=before_data,
|
||
after_data=user.to_dict(),
|
||
)
|
||
await db.commit()
|
||
await db.refresh(user)
|
||
return {"message": "Status updated", "user": _serialize_user(user)}
|
||
|
||
|
||
@router.delete("/{user_id}")
|
||
async def delete_user(
|
||
user_id: int,
|
||
tenant_id: int = Depends(get_tenant_id),
|
||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""删除用户(有未完成任务时禁止)。"""
|
||
if int(current_user.id) == user_id:
|
||
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
||
|
||
result = await db.execute(
|
||
select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id))
|
||
)
|
||
user = result.scalar_one_or_none()
|
||
if not user:
|
||
raise HTTPException(status_code=404, detail="User not found")
|
||
|
||
incomplete_count_result = await db.execute(
|
||
select(func.count(Task.id)).where(
|
||
and_(
|
||
Task.tenant_id == tenant_id,
|
||
Task.assignee_id == user_id,
|
||
func.lower(Task.status).notin_(COMPLETED_TASK_STATUSES),
|
||
)
|
||
)
|
||
)
|
||
incomplete_count = int(incomplete_count_result.scalar() or 0)
|
||
if incomplete_count > 0:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"User has {incomplete_count} incomplete tasks",
|
||
)
|
||
|
||
before_data = user.to_dict()
|
||
await db.execute(
|
||
delete(UserSkillBinding).where(
|
||
and_(
|
||
UserSkillBinding.tenant_id == tenant_id,
|
||
UserSkillBinding.user_id == user_id,
|
||
)
|
||
)
|
||
)
|
||
await db.execute(
|
||
delete(UserWorkload).where(
|
||
and_(
|
||
UserWorkload.tenant_id == tenant_id,
|
||
UserWorkload.user_id == user_id,
|
||
)
|
||
)
|
||
)
|
||
await db.execute(
|
||
delete(UserProject).where(
|
||
and_(
|
||
UserProject.tenant_id == tenant_id,
|
||
UserProject.user_id == user_id,
|
||
)
|
||
)
|
||
)
|
||
await db.delete(user)
|
||
await log_operation(
|
||
db,
|
||
tenant_id=tenant_id,
|
||
user_id=int(current_user.id),
|
||
project_id=None,
|
||
entity_type="user",
|
||
entity_id=str(user_id),
|
||
action="delete",
|
||
before_data=before_data,
|
||
after_data=None,
|
||
)
|
||
await db.commit()
|
||
return {"message": "User deleted successfully"}
|
||
|
||
|
||
@router.post("/bulk-delete")
|
||
async def bulk_delete_users(
|
||
body: BulkDeleteUsersRequest,
|
||
tenant_id: int = Depends(get_tenant_id),
|
||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""批量删除用户(带未完成任务保护)。"""
|
||
if not body.user_ids:
|
||
raise HTTPException(status_code=400, detail="user_ids required")
|
||
|
||
deleted: list[int] = []
|
||
skipped: list[dict[str, Any]] = []
|
||
|
||
for user_id in sorted(set(body.user_ids)):
|
||
if int(current_user.id) == user_id:
|
||
skipped.append({"user_id": user_id, "reason": "Cannot delete yourself"})
|
||
continue
|
||
|
||
user = (
|
||
await db.execute(
|
||
select(User).where(
|
||
and_(
|
||
User.tenant_id == tenant_id,
|
||
User.id == user_id,
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if not user:
|
||
skipped.append({"user_id": user_id, "reason": "User not found"})
|
||
continue
|
||
|
||
incomplete_count_result = await db.execute(
|
||
select(func.count(Task.id)).where(
|
||
and_(
|
||
Task.tenant_id == tenant_id,
|
||
Task.assignee_id == user_id,
|
||
func.lower(Task.status).notin_(COMPLETED_TASK_STATUSES),
|
||
)
|
||
)
|
||
)
|
||
incomplete_count = int(incomplete_count_result.scalar() or 0)
|
||
if incomplete_count > 0:
|
||
skipped.append(
|
||
{
|
||
"user_id": user_id,
|
||
"reason": f"User has {incomplete_count} incomplete tasks",
|
||
}
|
||
)
|
||
continue
|
||
|
||
await db.execute(
|
||
delete(UserSkillBinding).where(
|
||
and_(
|
||
UserSkillBinding.tenant_id == tenant_id,
|
||
UserSkillBinding.user_id == user_id,
|
||
)
|
||
)
|
||
)
|
||
await db.execute(
|
||
delete(UserWorkload).where(
|
||
and_(
|
||
UserWorkload.tenant_id == tenant_id,
|
||
UserWorkload.user_id == user_id,
|
||
)
|
||
)
|
||
)
|
||
await db.execute(
|
||
delete(UserProject).where(
|
||
and_(
|
||
UserProject.tenant_id == tenant_id,
|
||
UserProject.user_id == user_id,
|
||
)
|
||
)
|
||
)
|
||
await db.delete(user)
|
||
deleted.append(user_id)
|
||
|
||
await db.commit()
|
||
return {"deleted_count": len(deleted), "deleted_user_ids": deleted, "skipped": skipped}
|
||
|
||
|
||
@router.post("/bulk-assign")
|
||
async def bulk_assign_users(
|
||
body: BulkAssignUsersRequest,
|
||
tenant_id: int = Depends(get_tenant_id),
|
||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""批量分配项目/角色。"""
|
||
user_ids = sorted(set(body.user_ids or []))
|
||
if not user_ids:
|
||
raise HTTPException(status_code=400, detail="user_ids required")
|
||
if body.role is None and body.project_ids is None:
|
||
raise HTTPException(status_code=400, detail="role or project_ids required")
|
||
|
||
updated = 0
|
||
skipped: list[dict[str, Any]] = []
|
||
|
||
for user_id in user_ids:
|
||
user = (
|
||
await db.execute(
|
||
select(User).where(
|
||
and_(
|
||
User.tenant_id == tenant_id,
|
||
User.id == user_id,
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if not user:
|
||
skipped.append({"user_id": user_id, "reason": "User not found"})
|
||
continue
|
||
|
||
before_data = user.to_dict()
|
||
|
||
if body.role is not None:
|
||
if not _has_any_role(_enum_value(current_user.role), "BOSS"):
|
||
skipped.append({"user_id": user_id, "reason": "Only BOSS can bulk assign roles"})
|
||
continue
|
||
try:
|
||
user.role = _pack_roles(body.role)
|
||
except ValueError as exc:
|
||
skipped.append({"user_id": user_id, "reason": str(exc)})
|
||
continue
|
||
|
||
if body.project_ids is not None:
|
||
await _replace_user_projects(
|
||
db,
|
||
tenant_id=tenant_id,
|
||
user_id=user_id,
|
||
project_ids=body.project_ids,
|
||
)
|
||
|
||
await log_operation(
|
||
db,
|
||
tenant_id=tenant_id,
|
||
user_id=int(current_user.id),
|
||
project_id=None,
|
||
entity_type="user",
|
||
entity_id=str(user_id),
|
||
action="bulk_assign",
|
||
before_data=before_data,
|
||
after_data=user.to_dict(),
|
||
remark=f"role={body.role}, projects={body.project_ids}",
|
||
)
|
||
updated += 1
|
||
|
||
await db.commit()
|
||
return {"updated_count": updated, "skipped": skipped}
|
||
|
||
|
||
@router.put("/{user_id}/workload")
|
||
async def update_user_workload(
|
||
user_id: int,
|
||
workload: int = Query(..., ge=0, le=200, description="Workload value (0-200)"),
|
||
tenant_id: int = Depends(get_tenant_id),
|
||
current_user: User = Depends(require_self_or_manager),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""手动更新用户 workload 百分比。"""
|
||
_ = current_user
|
||
|
||
result = await db.execute(
|
||
select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id))
|
||
)
|
||
user = result.scalar_one_or_none()
|
||
if not user:
|
||
raise HTTPException(status_code=404, detail="User not found")
|
||
|
||
user.workload = max(0, min(200, int(workload)))
|
||
user.updated_at = datetime.utcnow()
|
||
await db.commit()
|
||
await db.refresh(user)
|
||
return _serialize_user(user)
|
||
|
||
|
||
def _normalize_import_row(raw: dict[str, Any]) -> dict[str, Any]:
|
||
lower_map = {str(k).strip().lower(): v for k, v in raw.items()}
|
||
return {
|
||
"username": str(lower_map.get("username", "")).strip(),
|
||
"password": str(lower_map.get("password", "")).strip(),
|
||
"name": str(lower_map.get("name", "")).strip(),
|
||
"email": str(lower_map.get("email", "")).strip() or None,
|
||
"role": str(lower_map.get("role", "FE")).strip().upper(),
|
||
"project_ids": _parse_int_list(str(lower_map.get("project_ids", ""))),
|
||
}
|
||
|
||
|
||
async def _iter_import_rows(file: UploadFile) -> list[dict[str, Any]]:
|
||
filename = (file.filename or "").lower()
|
||
content = await file.read()
|
||
|
||
if filename.endswith(".csv"):
|
||
text = content.decode("utf-8-sig")
|
||
reader = csv.DictReader(StringIO(text))
|
||
return [dict(row) for row in reader]
|
||
|
||
if filename.endswith(".xlsx") or filename.endswith(".xls"):
|
||
try:
|
||
from openpyxl import load_workbook # type: ignore
|
||
except Exception as exc:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"Excel import requires openpyxl: {exc}",
|
||
)
|
||
|
||
wb = load_workbook(filename=BytesIO(content), read_only=True)
|
||
sheet = wb.active
|
||
rows = list(sheet.iter_rows(values_only=True))
|
||
if not rows:
|
||
return []
|
||
headers = [str(v).strip() if v is not None else "" for v in rows[0]]
|
||
data_rows: list[dict[str, Any]] = []
|
||
for row in rows[1:]:
|
||
item = {
|
||
headers[idx]: row[idx]
|
||
for idx in range(min(len(headers), len(row)))
|
||
if headers[idx]
|
||
}
|
||
data_rows.append(item)
|
||
return data_rows
|
||
|
||
raise HTTPException(status_code=400, detail="Only .csv/.xlsx/.xls are supported")
|
||
|
||
|
||
@router.post("/import")
|
||
async def import_users(
|
||
file: UploadFile = File(...),
|
||
tenant_id: int = Depends(get_tenant_id),
|
||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""批量导入用户(最多 50 行)。"""
|
||
rows = await _iter_import_rows(file)
|
||
if not rows:
|
||
raise HTTPException(status_code=400, detail="Import file is empty")
|
||
if len(rows) > 50:
|
||
raise HTTPException(status_code=400, detail="Import rows exceed max limit: 50")
|
||
|
||
results: list[dict[str, Any]] = []
|
||
success_count = 0
|
||
|
||
for idx, raw in enumerate(rows, start=1):
|
||
row = _normalize_import_row(raw)
|
||
try:
|
||
if row["role"] not in {r.value for r in UserRole}:
|
||
raise ValueError(f"Invalid role: {row['role']}")
|
||
if not row["username"] or not row["password"]:
|
||
raise ValueError("username/password required")
|
||
|
||
data = UserCreate(
|
||
username=row["username"],
|
||
password=row["password"],
|
||
name=row["name"] or row["username"],
|
||
email=row["email"],
|
||
role=UserRole(row["role"]),
|
||
assigned_domains=[],
|
||
project_ids=row["project_ids"],
|
||
)
|
||
user = await _create_user_with_defaults(
|
||
db,
|
||
tenant_id=tenant_id,
|
||
user_data=data,
|
||
actor_id=int(current_user.id),
|
||
commit=True,
|
||
)
|
||
success_count += 1
|
||
results.append(
|
||
{
|
||
"row": idx,
|
||
"status": "success",
|
||
"user_id": str(user.id),
|
||
"message": "created",
|
||
}
|
||
)
|
||
except Exception as exc:
|
||
await db.rollback()
|
||
results.append(
|
||
{
|
||
"row": idx,
|
||
"status": "failed",
|
||
"message": str(exc),
|
||
}
|
||
)
|
||
|
||
return {
|
||
"total": len(rows),
|
||
"success": success_count,
|
||
"failed": len(rows) - success_count,
|
||
"results": results,
|
||
}
|