feat(org): user and PM management - list/define users, roles, project assignment (API + organization panel)
This commit is contained in:
+116
-10
@@ -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)}
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user