PMFlow/backend/app/api/users_api/helpers_user.py

268 lines
8.7 KiB
Python

from __future__ import annotations
from .shared import *
from .helpers_roles import _normalize_role_input, _pack_roles
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:
existing_rows = await db.execute(
select(UserProject.project_id).where(
and_(
UserProject.tenant_id == tenant_id,
UserProject.user_id == user_id,
)
)
)
existing_project_ids = {int(pid) for pid in existing_rows.scalars().all() if pid is not 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))
affected_project_ids = sorted(existing_project_ids.union({int(pid) for pid in unique_project_ids}))
for project_id in affected_project_ids:
await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=project_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
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")