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:
+34
-7
@@ -103,15 +103,17 @@ async def _verify_token(token: str) -> dict:
|
||||
class UserContext:
|
||||
user: User
|
||||
role: str | None
|
||||
project_id: object | None # uuid.UUID | None
|
||||
project_id: object | None # uuid.UUID | None — default (first) project of a PM
|
||||
project_ids: list # every project this PM manages (1:N — empty for non-PMs)
|
||||
display_name: str
|
||||
|
||||
|
||||
async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
|
||||
"""Auto-provision the `users` row from token claims; resolve role + PM project.
|
||||
"""Auto-provision the `users` row from token claims; resolve role + PM projects.
|
||||
|
||||
First login: creates the row with role NULL (an admin assigns it later).
|
||||
PMs: resolves their single project via projects.pm_user_id.
|
||||
PMs: resolves EVERY project they manage (projects.pm_user_id = user);
|
||||
`project_id` stays the first one (oldest) for backward compatibility.
|
||||
"""
|
||||
casdoor_id = str(claims.get("sub") or claims.get("id") or "")
|
||||
if not casdoor_id:
|
||||
@@ -134,13 +136,38 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
project_id = None
|
||||
project_ids: list = []
|
||||
if user.role == "pm":
|
||||
project = await db.scalar(select(Project).where(Project.pm_user_id == user.id))
|
||||
project_id = project.id if project else None
|
||||
projects = (
|
||||
await db.execute(
|
||||
select(Project)
|
||||
.where(Project.pm_user_id == user.id)
|
||||
.order_by(Project.created_at, Project.id)
|
||||
)
|
||||
).scalars().all()
|
||||
project_ids = [p.id for p in projects]
|
||||
project_id = project_ids[0] if project_ids else None
|
||||
|
||||
display_name = f"{user.first_name} {user.last_name}".strip() or user.casdoor_id
|
||||
return UserContext(user=user, role=user.role, project_id=project_id, display_name=display_name)
|
||||
return UserContext(
|
||||
user=user,
|
||||
role=user.role,
|
||||
project_id=project_id,
|
||||
project_ids=project_ids,
|
||||
display_name=display_name,
|
||||
)
|
||||
|
||||
|
||||
async def pm_project_ids(db: AsyncSession, user: User) -> list:
|
||||
"""Every project id managed by the given PM user (oldest first)."""
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(Project.id)
|
||||
.where(Project.pm_user_id == user.id)
|
||||
.order_by(Project.created_at, Project.id)
|
||||
)
|
||||
).scalars().all()
|
||||
return list(rows)
|
||||
|
||||
|
||||
async def get_optional_user(request: Request, db: AsyncSession) -> UserContext | None:
|
||||
|
||||
Reference in New Issue
Block a user