feat(pm): multi-project PMs (1:N) with project switching, scoping validation and org multi-select
Task 1 — PM 1:N + access-loss handling:
- projects.pm_user_id had no UNIQUE constraint (verified live + in schema); added a lookup index via init_db
- auth: UserContext.project_ids = every project of a PM (oldest first); project_id stays the first for compatibility
- GET /users/me/projects — assigned projects of the caller (drives the switcher + revocation validation)
- login / /users/me / /users list now return the full projects array per user
- /inventory/* and /pm/* scopes accept an explicit ?project_id=… and verify the PM manages it (403 otherwise); without the param they fall back to the first project; admin/manager use it as an optional filter
- POST /users/{id}/project is multi-aware: assigns one project (replacing that project's previous PM) and KEEPS the user's other assignments; null clears all
- PATCH /projects/{id} accepts pm_user_id (admin / procurement manager only)
- frontend: auth store holds projects + the active project (persisted via the existing localStorage store); RequirePmProjects re-validates on load, falls back when the active project was revoked, and shows a dedicated no-projects screen with logout (no redirect loop, no re-auth)
- Panels menu: «مدیریت انبار» gets a ChevronDown accordion listing the PM's projects with «✓ فعلی»; selecting one switches only the active project and reloads the section data
- inventory/pm API calls now send the active project_id explicitly
Task 2 — Organization page:
- new searchable ProjectMultiSelect combobox (chips with ×, live Persian filtering via normalize, assigned projects hidden) replaces the single select for PM users
- add = PATCH project pm_user_id=<user>; × = pm_user_id=null; table and project cards sync immediately; RBAC unchanged (role column admin-only, combobox editable for admin + procurement manager)
Verified locally: 17 scripted checks — multi-project assignment without unique conflicts, /users/me/projects, explicit project_id authorization (foreign project 403 on /inventory, /pm/on-the-way, /pm/receipts), PM blocked from pm_user_id changes, legacy assign endpoint keeps other assignments, revocation removes the project from the list, zero-project PM gets an empty list and a guarded inventory endpoint. All test data cleaned up afterwards.
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
"""Users — profile + role assignment + organization management (v2.6, Casdoor-backed).
|
||||
|
||||
Organization panel endpoints (admin / procurement manager — same person):
|
||||
- GET /users — list every user with role + assigned project.
|
||||
- GET /users — list every user with role + assigned projects.
|
||||
- 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).
|
||||
«pm») also unassigns the user from every project.
|
||||
- POST /users/{id}/project — assign/unassign ONE project (multi-project:
|
||||
other assignments are kept; project_id=None
|
||||
clears all; one PM per project).
|
||||
- GET /users/me/projects — every project assigned to the caller (PM).
|
||||
|
||||
Role management is open to BOTH «admin» and «procurement_manager» (they are
|
||||
the same person in this system), unlike the plain require_role("admin").
|
||||
@@ -41,7 +43,11 @@ USER_ROLE_LABELS = {
|
||||
}
|
||||
|
||||
|
||||
def user_to_dict(user: User, project: Project | None = None) -> dict:
|
||||
def user_to_dict(user: User, projects: list[Project] | None = None) -> dict:
|
||||
"""Serialize a user. `projects` = every project the PM manages (1:N);
|
||||
project_id/project_name stay as the first (oldest) one for compatibility."""
|
||||
projects = projects or []
|
||||
first = projects[0] if projects else None
|
||||
return {
|
||||
"id": str(user.id),
|
||||
"casdoor_id": user.casdoor_id,
|
||||
@@ -50,8 +56,9 @@ def user_to_dict(user: User, project: Project | None = None) -> dict:
|
||||
"phone": user.phone,
|
||||
"role": user.role,
|
||||
"role_label": USER_ROLE_LABELS.get(user.role) if user.role else None,
|
||||
"project_id": str(project.id) if project else None,
|
||||
"project_name": project.name if project else None,
|
||||
"project_id": str(first.id) if first else None,
|
||||
"project_name": first.name if first else None,
|
||||
"projects": [{"id": str(p.id), "name": p.name} for p in projects],
|
||||
"created_at": to_iso(user.created_at),
|
||||
}
|
||||
|
||||
@@ -63,14 +70,28 @@ async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserCont
|
||||
return ctx
|
||||
|
||||
|
||||
async def _user_project(db: AsyncSession, user: User) -> Project | None:
|
||||
async def _user_projects(db: AsyncSession, user: User) -> list[Project]:
|
||||
"""Every project the user manages as PM (oldest first; [] for non-PMs)."""
|
||||
if user.role != "pm":
|
||||
return None
|
||||
return await db.scalar(select(Project).where(Project.pm_user_id == user.id))
|
||||
return []
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(Project)
|
||||
.where(Project.pm_user_id == user.id)
|
||||
.order_by(Project.created_at, Project.id)
|
||||
)
|
||||
).scalars().all()
|
||||
return list(rows)
|
||||
|
||||
|
||||
async def _user_project(db: AsyncSession, user: User) -> Project | None:
|
||||
"""First (oldest) project of a PM — legacy single-project view."""
|
||||
projects = await _user_projects(db, user)
|
||||
return projects[0] if projects else None
|
||||
|
||||
|
||||
async def _unassign_projects(db: AsyncSession, user_id) -> None:
|
||||
"""Clear the user's pm assignment on every project (one project per PM)."""
|
||||
"""Clear the user's pm assignment on every project (role revocation)."""
|
||||
await db.execute(
|
||||
update(Project).where(Project.pm_user_id == user_id).values(pm_user_id=None)
|
||||
)
|
||||
@@ -104,8 +125,19 @@ async def _casdoor_users() -> list[dict]:
|
||||
|
||||
@router.get("/me")
|
||||
async def me(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
project = await _user_project(db, ctx.user) if ctx.role == "pm" else None
|
||||
return {"data": user_to_dict(ctx.user, project)}
|
||||
projects = await _user_projects(db, ctx.user)
|
||||
return {"data": user_to_dict(ctx.user, projects)}
|
||||
|
||||
|
||||
@router.get("/me/projects")
|
||||
async def my_projects(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
"""Every project assigned to the caller (empty list for non-PM roles).
|
||||
|
||||
Drives the PM project switcher: the frontend validates its persisted
|
||||
active project against this list and falls back gracefully when a
|
||||
project is revoked."""
|
||||
projects = await _user_projects(db, ctx.user)
|
||||
return {"data": [{"id": str(p.id), "name": p.name} for p in projects]}
|
||||
|
||||
|
||||
@router.get("")
|
||||
@@ -113,15 +145,22 @@ async def list_users(
|
||||
_: UserContext = Depends(_org_manager),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Every user (with role + assigned project) — Organization panel."""
|
||||
"""Every user (with role + assigned projects) — 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 = {}
|
||||
by_user: 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}
|
||||
projs = (
|
||||
await db.execute(
|
||||
select(Project)
|
||||
.where(Project.pm_user_id.in_(pm_ids))
|
||||
.order_by(Project.created_at, Project.id)
|
||||
)
|
||||
).scalars().all()
|
||||
for p in projs:
|
||||
by_user.setdefault(p.pm_user_id, []).append(p)
|
||||
return {
|
||||
"data": [user_to_dict(u, projects.get(u.id)) for u in users],
|
||||
"data": [user_to_dict(u, by_user.get(u.id, [])) for u in users],
|
||||
"meta": {"total": len(users)},
|
||||
}
|
||||
|
||||
@@ -166,8 +205,8 @@ async def update_user_role(
|
||||
await _unassign_projects(db, user.id)
|
||||
user.role = payload.role
|
||||
await db.flush()
|
||||
project = await _user_project(db, user)
|
||||
return {"data": user_to_dict(user, project)}
|
||||
projects = await _user_projects(db, user)
|
||||
return {"data": user_to_dict(user, projects)}
|
||||
|
||||
|
||||
@router.post("/sync")
|
||||
@@ -204,12 +243,19 @@ async def sync_casdoor_users(
|
||||
|
||||
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 = {}
|
||||
by_user: 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}
|
||||
projs = (
|
||||
await db.execute(
|
||||
select(Project)
|
||||
.where(Project.pm_user_id.in_(pm_ids))
|
||||
.order_by(Project.created_at, Project.id)
|
||||
)
|
||||
).scalars().all()
|
||||
for p in projs:
|
||||
by_user.setdefault(p.pm_user_id, []).append(p)
|
||||
return {
|
||||
"data": [user_to_dict(u, projects.get(u.id)) for u in users],
|
||||
"data": [user_to_dict(u, by_user.get(u.id, [])) for u in users],
|
||||
"meta": {"total": len(users), "created": created},
|
||||
}
|
||||
|
||||
@@ -221,11 +267,13 @@ async def assign_user_project(
|
||||
_: UserContext = Depends(_org_manager),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Assign the user as PM of one project (project_id=None unassigns).
|
||||
"""Assign the user as PM of ONE project (multi-project aware).
|
||||
|
||||
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.
|
||||
- project_id given: that project gets this PM (a previous PM there is
|
||||
replaced); the user's OTHER project assignments are kept.
|
||||
- project_id=None: clears ALL of the user's project assignments.
|
||||
|
||||
Rules: the user must hold the «pm» role; a project has at most one PM.
|
||||
"""
|
||||
user = await db.get(User, user_id)
|
||||
if user is None:
|
||||
@@ -233,8 +281,8 @@ async def assign_user_project(
|
||||
if user.role != "pm":
|
||||
raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="role")
|
||||
|
||||
await _unassign_projects(db, user.id)
|
||||
if payload.project_id is None:
|
||||
await _unassign_projects(db, user.id)
|
||||
await db.flush()
|
||||
return {"data": user_to_dict(user)}
|
||||
|
||||
@@ -243,4 +291,4 @@ async def assign_user_project(
|
||||
raise NotFoundError("پروژه یافت نشد", field="project_id")
|
||||
project.pm_user_id = user.id
|
||||
await db.flush()
|
||||
return {"data": user_to_dict(user, project)}
|
||||
return {"data": user_to_dict(user, await _user_projects(db, user))}
|
||||
|
||||
Reference in New Issue
Block a user