PMFlow/backend/app/api/users.py

1086 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""用户管理 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.exc import IntegrityError
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
def _translate_user_integrity_error(exc: IntegrityError) -> str:
message = str(getattr(exc, "orig", exc)).lower()
if "users.username" in message or "username" in message:
return "用户名已存在"
if "users.email" in message or "email" in message:
return "邮箱已存在"
return "用户数据冲突,请检查用户名或邮箱是否重复"
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))
# bulk delete(UserProject) 不会触发 ORM 对象事件,这里手动注册缓存失效意图。
cache_payload = db.sync_session.info.get("_pmflow_cache_invalidation")
if not isinstance(cache_payload, dict):
cache_payload = {
"tenant_ids": set(),
"invalidate_system": False,
"invalidate_project_list": False,
"invalidate_signal_pending": False,
"user_ids": set(),
}
db.sync_session.info["_pmflow_cache_invalidation"] = cache_payload
cache_payload["tenant_ids"].add(int(tenant_id))
cache_payload["invalidate_system"] = True
cache_payload["invalidate_project_list"] = True
cache_payload["user_ids"].add(int(user_id))
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().subquery())
)
total = int(total_result.scalar() or 0)
result = await db.execute(
query.distinct()
.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 IntegrityError as exc:
await db.rollback()
raise HTTPException(status_code=400, detail=_translate_user_integrity_error(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(),
)
try:
await db.commit()
await db.refresh(user)
except IntegrityError as exc:
await db.rollback()
raise HTTPException(status_code=400, detail=_translate_user_integrity_error(exc))
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")
try:
user.password_hash = AuthService.hash_password(body.new_password)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
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,
}