"""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)}