feat(org): user and PM management - list/define users, roles, project assignment (API + organization panel)

This commit is contained in:
ali_mohaghegh
2026-08-27 15:04:24 +03:30
parent 034c05c713
commit f48f3752af
4 changed files with 403 additions and 22 deletions
+116 -10
View File
@@ -1,17 +1,27 @@
"""Users — profile + role assignment (v2.6, Casdoor-backed).
"""Users — profile + role assignment + organization management (v2.6, Casdoor-backed).
GET /users/me — the caller's profile: role, PM's project, display name.
PATCH /users/{id} — assign role (admin only).
Organization panel endpoints (admin / procurement manager — same person):
- GET /users — list every user with role + assigned project.
- POST /users — define a new user (manual identity, casdoor_id optional).
- PATCH /users/{id} — set/clear role; clearing (or switching away from
«pm») also unassigns the user from their project.
- POST /users/{id}/project — assign/unassign the user's single project
(project_id=None unassigns; one PM per project).
Role management is open to BOTH «admin» and «procurement_manager» (they are
the same person in this system), unlike the plain require_role("admin").
"""
import uuid
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_current_user, require_role
from app.auth import UserContext, get_current_user
from app.db.engine import get_db
from app.models.procurement import Project, User
from app.schemas.procurement import UserRoleUpdate
from app.utils.errors import NotFoundError
from app.schemas.procurement import UserCreate, UserProjectUpdate, UserRoleUpdate
from app.utils.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError
from app.utils.serialization import to_iso
router = APIRouter(prefix="/users", tags=["users"])
@@ -38,23 +48,119 @@ def user_to_dict(user: User, project: Project | None = None) -> dict:
}
async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserContext:
"""Admin OR procurement manager (the same person) — organization management."""
if ctx.role not in ("admin", "procurement_manager"):
raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست")
return ctx
async def _user_project(db: AsyncSession, user: User) -> Project | None:
if user.role != "pm":
return None
return await db.scalar(select(Project).where(Project.pm_user_id == user.id))
async def _unassign_projects(db: AsyncSession, user_id) -> None:
"""Clear the user's pm assignment on every project (one project per PM)."""
await db.execute(
update(Project).where(Project.pm_user_id == user_id).values(pm_user_id=None)
)
@router.get("/me")
async def me(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
project = await db.scalar(select(Project).where(Project.pm_user_id == ctx.user.id)) if ctx.role == "pm" else None
project = await _user_project(db, ctx.user) if ctx.role == "pm" else None
return {"data": user_to_dict(ctx.user, project)}
@router.get("")
async def list_users(
_: UserContext = Depends(_org_manager),
db: AsyncSession = Depends(get_db),
):
"""Every user (with role + assigned project) — Organization panel."""
users = (await db.execute(select(User).order_by(User.created_at))).scalars().all()
pm_ids = {u.id for u in users if u.role == "pm"}
projects: dict = {}
if pm_ids:
projs = (await db.execute(select(Project).where(Project.pm_user_id.in_(pm_ids)))).scalars().all()
projects = {p.pm_user_id: p for p in projs}
return {
"data": [user_to_dict(u, projects.get(u.id)) for u in users],
"meta": {"total": len(users)},
}
@router.post("", status_code=201)
async def create_user(
payload: UserCreate,
_: UserContext = Depends(_org_manager),
db: AsyncSession = Depends(get_db),
):
"""Define a new user (manual identity — casdoor_id optional)."""
casdoor_id = (payload.casdoor_id or "").strip() or f"manual-{uuid.uuid4()}"
duplicate = await db.scalar(select(User).where(User.casdoor_id == casdoor_id))
if duplicate is not None:
raise ConflictError("کاربری با این شناسه قبلاً ثبت شده است", field="casdoor_id")
user = User(
casdoor_id=casdoor_id,
first_name=payload.first_name,
last_name=payload.last_name or "",
phone=payload.phone,
role=payload.role,
)
db.add(user)
await db.flush()
return {"data": user_to_dict(user)}
@router.patch("/{user_id}")
async def update_user_role(
user_id,
payload: UserRoleUpdate,
_: UserContext = Depends(require_role("admin")),
_: UserContext = Depends(_org_manager),
db: AsyncSession = Depends(get_db),
):
"""Set/clear a role. Revoking (or switching away from «pm») unassigns projects."""
user = await db.get(User, user_id)
if user is None:
raise NotFoundError("کاربر یافت نشد")
if user.role == "pm" and payload.role != "pm":
await _unassign_projects(db, user.id)
user.role = payload.role
await db.flush()
project = await db.scalar(select(Project).where(Project.pm_user_id == user.id)) if user.role == "pm" else None
project = await _user_project(db, user)
return {"data": user_to_dict(user, project)}
@router.post("/{user_id}/project")
async def assign_user_project(
user_id,
payload: UserProjectUpdate,
_: UserContext = Depends(_org_manager),
db: AsyncSession = Depends(get_db),
):
"""Assign the user as PM of one project (project_id=None unassigns).
Rules: the user must hold the «pm» role; a PM manages ONE project at a
time (assignment on other projects is cleared); assigning a new PM to a
project replaces the previous PM there.
"""
user = await db.get(User, user_id)
if user is None:
raise NotFoundError("کاربر یافت نشد")
if user.role != "pm":
raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="role")
await _unassign_projects(db, user.id)
if payload.project_id is None:
await db.flush()
return {"data": user_to_dict(user)}
project = await db.get(Project, payload.project_id)
if project is None:
raise NotFoundError("پروژه یافت نشد", field="project_id")
project.pm_user_id = user.id
await db.flush()
return {"data": user_to_dict(user, project)}
+24 -1
View File
@@ -188,8 +188,31 @@ class PMReceiptCreate(APIModel):
quality_note: str | None = None
class UserCreate(APIModel):
"""Define a new user from the Organization panel (admin / procurement manager).
casdoor_id is optional: real Casdoor users auto-provision on their first
login; a manual entry gets a synthetic casdoor_id so the row can exist
before the person ever signs in.
"""
first_name: str = Field(min_length=1)
last_name: str = ""
phone: str | None = None
casdoor_id: str | None = None
role: Literal["pm", "procurement_manager", "admin"] | None = None
class UserRoleUpdate(APIModel):
role: Literal["pm", "procurement_manager", "admin"]
"""role=None revokes the role (and any project assignment)."""
role: Literal["pm", "procurement_manager", "admin"] | None = None
class UserProjectUpdate(APIModel):
"""Assign/unassign the user's single project (project_id=None unassigns)."""
project_id: UUID | None = None
class RejectQuoteIn(APIModel):