282 lines
9.3 KiB
Python
282 lines
9.3 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 "phone_e164" in message or "ix_users_phone_e164" in message:
|
|
return "手机号已绑定其他账号"
|
|
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("邮箱已存在")
|
|
|
|
phone_payload: dict[str, str | None] = {
|
|
"phone_country_code": None,
|
|
"phone_number": None,
|
|
"phone_e164": None,
|
|
}
|
|
if user_data.phone_number:
|
|
from ...services.phone_auth_service import normalize_phone
|
|
|
|
phone_payload = normalize_phone(user_data.phone_country_code, user_data.phone_number)
|
|
|
|
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(),
|
|
phone_country_code=phone_payload["phone_country_code"],
|
|
phone_number=phone_payload["phone_number"],
|
|
phone_e164=phone_payload["phone_e164"],
|
|
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", "BUILDER")).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")
|