Files
ERP/backend/app/routers/users.py
T
MMD 934b74ebbd Squashed 'pm/' content from commit 91f3271
git-subtree-dir: pm
git-subtree-split: 91f3271d866fa8ee471cfb6793e334b83e6d2b6e
2026-10-07 15:14:20 +03:30

187 lines
6.1 KiB
Python

"""User and project-assignment administration (role `project_manager` only).
Creating the accounts that hold a scoped role and deciding *which* projects the
scoped role may touch is what makes the RBAC in app/authz.py usable; nothing
here changes an operational record.
"""
import uuid
from fastapi import APIRouter, Depends
from pydantic import BaseModel, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.authz import ROLE_LABELS, AuthContext, Perm, Role, require_permission, role_of
from app.db import get_db
from app.dev_accounts import clear_assignments
from app.errors import NotFoundError, ValidationError
from app.models import PMUser, Project, ProjectAssignment
from app.security import hash_password
from app.serializers import _iso
router = APIRouter(prefix="/users", tags=["users"])
manage = require_permission(Perm.users_manage)
def _role_value(value: str) -> str:
try:
return Role(value).value
except ValueError as exc:
raise ValidationError(f"نقش نامعتبر است: {value}", field="role") from exc
async def _assigned_project_ids(db: AsyncSession, user_id) -> list[str]:
"""Projects assigned to a user, as native UUIDs."""
rows = (
await db.execute(
select(ProjectAssignment.project_id).where(
ProjectAssignment.user_id == user_id
)
)
).scalars().all()
return sorted(str(pid) for pid in rows if pid is not None)
async def _user_dict(db: AsyncSession, user: PMUser) -> dict:
role = role_of(user)
return {
"id": str(user.id),
"username": user.username,
"display_name": user.display_name,
"role": role.value,
"role_label": ROLE_LABELS.get(role.value, role.value),
"scoped": role in (Role.project_supervisor,),
"project_ids": await _assigned_project_ids(db, user.id),
"created_at": _iso(user.created_at),
}
@router.get("")
async def list_users(ctx: AuthContext = Depends(manage), db: AsyncSession = Depends(get_db)):
users = (await db.execute(select(PMUser).order_by(PMUser.username))).scalars().all()
return {"data": [await _user_dict(db, u) for u in users]}
class UserIn(BaseModel):
username: str
password: str
display_name: str = ""
role: str = Role.project_supervisor.value
@field_validator("username")
@classmethod
def _username(cls, v: str) -> str:
if not v.strip():
raise ValueError("نام کاربری الزامی است")
return v.strip()
@field_validator("password")
@classmethod
def _password(cls, v: str) -> str:
if len(v) < 6:
raise ValueError("رمز عبور باید حداقل ۶ نویسه باشد")
return v
@router.post("")
async def create_user(payload: UserIn, ctx: AuthContext = Depends(manage), db: AsyncSession = Depends(get_db)):
existing = await db.scalar(select(PMUser).where(PMUser.username == payload.username))
if existing is not None:
raise ValidationError("این نام کاربری قبلاً ثبت شده است", field="username")
user = PMUser(
username=payload.username,
password_hash=hash_password(payload.password),
display_name=payload.display_name.strip(),
role=_role_value(payload.role),
)
db.add(user)
await db.commit()
return {"data": await _user_dict(db, user)}
class UserPatch(BaseModel):
display_name: str | None = None
role: str | None = None
password: str | None = None
@field_validator("password")
@classmethod
def _password(cls, v: str | None) -> str | None:
if v is not None and len(v) < 6:
raise ValueError("رمز عبور باید حداقل ۶ نویسه باشد")
return v
@router.patch("/{user_id}")
async def patch_user(
user_id: uuid.UUID,
payload: UserPatch,
ctx: AuthContext = Depends(manage),
db: AsyncSession = Depends(get_db),
):
user = await db.get(PMUser, user_id)
if user is None:
raise NotFoundError("کاربر یافت نشد")
if payload.display_name is not None:
user.display_name = payload.display_name.strip()
if payload.password:
user.password_hash = hash_password(payload.password)
if payload.role is not None:
new_role = _role_value(payload.role)
# never let a manager remove their own manager role (lock-out guard)
if user.id == ctx.user.id and new_role != Role.project_manager.value:
raise ValidationError("نقش مدیر فعلی خود را نمی‌توانید تغییر دهید", field="role")
user.role = new_role
await db.commit()
return {"data": await _user_dict(db, user)}
class ProjectsIn(BaseModel):
"""Native project UUIDs (the live world). Legacy integer ids still accepted."""
project_ids: list[str] = []
@router.put("/{user_id}/projects")
async def set_user_projects(
user_id: uuid.UUID,
payload: ProjectsIn,
ctx: AuthContext = Depends(manage),
db: AsyncSession = Depends(get_db),
):
"""Replace a user's assigned projects (the scope a supervisor is limited to)."""
user = await db.get(PMUser, user_id)
if user is None:
raise NotFoundError("کاربر یافت نشد")
native_ids: list[uuid.UUID] = []
legacy_ids: list[int] = []
unknown: list[str] = []
for raw in dict.fromkeys(payload.project_ids):
raw = str(raw).strip()
parsed = None
try:
parsed = uuid.UUID(raw)
except ValueError:
parsed = None
if parsed is not None:
if await db.get(Project, parsed) is None:
unknown.append(raw)
else:
native_ids.append(parsed)
else:
unknown.append(raw)
if unknown:
raise ValidationError(
"پروژه(های) نامعتبر: " + "، ".join(unknown), field="project_ids"
)
await clear_assignments(db, user.id)
for project_uuid in native_ids:
db.add(ProjectAssignment(user_id=user.id, project_id=project_uuid))
await db.commit()
return {"data": await _user_dict(db, user)}