187 lines
6.1 KiB
Python
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)}
|